22 Commits

Author SHA1 Message Date
f42ecc70c3 Prepare source release validation 2026-09-06 22:47:12 +00:00
2836f825b6 Harden configuration boundaries with fuzz coverage 2026-09-06 22:35:01 +00:00
a8bfb8848d Add maintained consumer migration examples 2026-09-06 22:26:14 +00:00
6ef240b1a0 Add safe historical configuration explanations 2026-09-06 22:14:54 +00:00
d4ec8607d1 Apply explicit CLI configuration overrides 2026-09-06 21:55:35 +00:00
d6e32d8807 Add typed environment secret references 2026-09-06 21:40:15 +00:00
16f4212770 Add explicit secrets directory bootstrap 2026-09-06 21:29:25 +00:00
828fdd38eb Add redacted effective rendering 2026-09-06 21:21:11 +00:00
26cece6d53 Unify layered provenance history 2026-09-06 21:09:52 +00:00
ef0e6078f7 Add selected profile overlays 2026-09-06 21:03:45 +00:00
71a73bcfd8 Complete bounded glob imports 2026-09-06 20:51:21 +00:00
9f04c09be3 Add explicit path import traversal 2026-09-06 20:41:40 +00:00
3257b2124c Implement strict document composition 2026-09-06 20:26:57 +00:00
a0cd0977ee Complete single-source semantic validation 2026-09-06 20:19:58 +00:00
0da4260ad6 Add audited application resolution 2026-09-06 20:14:55 +00:00
cc41653bfd Add single-source loading and version checks 2026-09-06 20:03:22 +00:00
21e3f633bc Decode typed configuration over defaults 2026-09-06 19:52:45 +00:00
15172294b5 Implement canonical projection and detached copies 2026-09-06 19:44:36 +00:00
2b65876a7d Build reusable schema descriptors 2026-09-06 19:36:14 +00:00
7c78da9cd2 Add bounded sources and strict YAML parsing 2026-09-06 19:30:31 +00:00
14b10cb6a5 Add semantic paths and safe diagnostics 2026-09-06 19:19:24 +00:00
8e3cc40c78 Establish ConfigKit module and extraction baseline 2026-09-06 19:13:36 +00:00
96 changed files with 16761 additions and 1575 deletions

View File

@@ -2,9 +2,40 @@
An opinionated Go library for applications that use YAML configuration files.
ConfigKit is in pre-implementation. This repository currently contains its design
and contributor policies; no usable Go library has been implemented yet.
ConfigKit's initial loader accepts one strict YAML
source (or explicit defaults-only input), optionally traverses explicit path and glob
imports, strictly composes their disjoint definitions, applies typed defaults, and
can overlay one selected fixed profile and check an application schema version. It
returns detached metadata and safe diagnostics.
Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. Secret
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
references resolve environment names before validation, and explicit
secrets-directory bootstrap is available as a separate startup operation. Explicit
CLI override records apply above profiles. Value explanation is available through
detached structured records and deterministic redacted output.
The [initial target design](docs/roadmap/initial.md) describes planned behavior.
The [implementation plan](docs/roadmap/implementation.md) owns the pending stages.
See the [development guide](docs/development.md) for repository orientation.
```go
type Config struct {
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
}
func ReadConfig(path string) (Config, error) {
result, err := configkit.Load[Config](configkit.File(path))
if err != nil {
return Config{}, err
}
return result.Config, nil
}
```
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
[development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication
steps.

179
compose.go Normal file
View File

@@ -0,0 +1,179 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// composedDocument is the private handoff between source assembly and later
// precedence layers. Claims remain ordered by source and declaration traversal;
// origins retain every source that contributes to a composite mapping.
type composedDocument struct {
root *document.Node
claims []compositionClaim
origins map[string]Origin
overlays []overlayRecord
applications []historyApplication
inputBytes int64
inputNodes int
}
type compositionClaim struct {
path Path
kind document.Kind
location Location
}
type compositionClaims struct {
ordered []compositionClaim
byPath map[string][]compositionClaim
}
// composeDocuments recursively joins disjoint mapping entries. Source order
// controls only output traversal and diagnostics: every non-mapping overlap is
// rejected before a result is assembled.
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
if len(inputs) == 0 {
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
}
collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
for _, input := range inputs {
if input == nil || input.Kind != document.Mapping {
var location *Location
if input != nil {
converted := publicLocation(input.Location)
location = &converted
}
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
}
collected.append(input, Path{})
}
for _, claim := range collected.ordered {
claims := collected.byPath[claim.path.String()]
if len(claims) < 2 || allMappingClaims(claims) {
continue
}
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
}
root := document.Clone(inputs[0])
for _, input := range inputs[1:] {
mergeDisjointMappings(root, input)
}
applications := make([]historyApplication, 0, len(inputs))
for _, input := range inputs {
applications = append(applications, historyApplication{layer: LayerBase, value: input})
}
return &composedDocument{
root: root,
claims: cloneCompositionClaims(collected.ordered),
origins: compositionOrigins(collected.ordered),
applications: applications,
}, nil
}
func (c *compositionClaims) append(node *document.Node, path Path) {
claim := compositionClaim{
path: path.clone(),
kind: node.Kind,
location: publicLocation(node.Location),
}
c.ordered = append(c.ordered, claim)
pointer := path.String()
c.byPath[pointer] = append(c.byPath[pointer], claim)
if node.Kind != document.Mapping {
return
}
for _, entry := range node.Mapping {
c.append(entry.Value, path.Key(entry.Key))
}
}
func allMappingClaims(claims []compositionClaim) bool {
for _, claim := range claims {
if claim.kind != document.Mapping {
return false
}
}
return true
}
func mergeDisjointMappings(target, incoming *document.Node) {
for _, incomingEntry := range incoming.Mapping {
index := documentEntryIndex(target.Mapping, incomingEntry.Key)
if index < 0 {
cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value)
target.Mapping = append(target.Mapping, cloned)
continue
}
// Claim validation guarantees that the only remaining overlap is a
// mapping pair. Empty mappings naturally contribute no child entries.
mergeDisjointMappings(target.Mapping[index].Value, incomingEntry.Value)
}
}
func documentEntryIndex(entries []document.Entry, key string) int {
for index := range entries {
if entries[index].Key == key {
return index
}
}
return -1
}
func compositionOrigins(claims []compositionClaim) map[string]Origin {
origins := make(map[string]Origin, len(claims))
for _, claim := range claims {
pointer := claim.path.String()
origin := origins[pointer]
origin.Layer = LayerBase
origin.Locations = append(origin.Locations, claim.location)
origins[pointer] = origin
}
return origins
}
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
for pointer, origin := range origins {
if _, present := decoded.presence[pointer]; present {
decoded.origins[pointer] = cloneOrigin(origin)
}
}
}
func cloneCompositionClaims(claims []compositionClaim) []compositionClaim {
cloned := make([]compositionClaim, len(claims))
for index, claim := range claims {
cloned[index] = claim
cloned[index].path = claim.path.clone()
}
return cloned
}
func compositionInvalidDiagnostic(message string, location *Location) Diagnostic {
return Diagnostic{
Code: CodeComposeInvalid, Severity: SeverityError, Message: message,
Primary: cloneLocation(location),
}
}
func compositionConflictDiagnostic(path Path, claims []compositionClaim) Diagnostic {
primary := claims[0].location
related := make([]Location, 0, len(claims)-1)
for _, claim := range claims[1:] {
related = append(related, claim.location)
}
return Diagnostic{
Code: CodeComposeConflict,
Severity: SeverityError,
Path: path.clone(),
Message: "configuration path is defined by multiple sources",
Primary: &primary,
Related: related,
}
}
func publicLocation(location document.Location) Location {
return Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
}

194
compose_test.go Normal file
View File

@@ -0,0 +1,194 @@
package configkit
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
base := parseCompositionDocument(t, "source-1", `services:
api:
enabled: true
empty: {}
`)
fragment := parseCompositionDocument(t, "source-2", `services:
worker:
enabled: false
values: [one, two]
`)
baseBefore := document.Clone(base)
fragmentBefore := document.Clone(fragment)
composed, diagnostics := composeDocuments(base, fragment)
if len(diagnostics) != 0 {
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
}
for _, path := range []Path{
Keys("services", "api", "enabled"),
Keys("services", "worker", "enabled"),
Keys("empty"),
Keys("values"),
} {
if _, ok := documentAtPath(composed.root, path); !ok {
t.Errorf("composed document is missing %s", path.String())
}
}
serviceOrigin := composed.origins[Keys("services").String()]
wantLocations := []Location{
{SourceID: "source-1", Line: 2, Column: 3},
{SourceID: "source-2", Line: 2, Column: 3},
}
if !reflect.DeepEqual(serviceOrigin, Origin{Layer: LayerBase, Locations: wantLocations}) {
t.Fatalf("services origin = %#v, want composite origin", serviceOrigin)
}
wantClaimPaths := []string{"", "/services", "/services/api", "/services/api/enabled", "/empty", "", "/services", "/services/worker", "/services/worker/enabled", "/values"}
gotClaimPaths := make([]string, len(composed.claims))
for index, claim := range composed.claims {
gotClaimPaths[index] = claim.path.String()
}
if !reflect.DeepEqual(gotClaimPaths, wantClaimPaths) {
t.Fatalf("claim paths = %#v, want %#v", gotClaimPaths, wantClaimPaths)
}
reversed, reversedDiagnostics := composeDocuments(fragment, base)
if len(reversedDiagnostics) != 0 {
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
}
if deltas := compareDocumentTrees(composed.root, reversed.root); len(deltas) != 0 {
t.Fatalf("input order changed effective values: %#v", deltas)
}
if !reflect.DeepEqual(base, baseBefore) || !reflect.DeepEqual(fragment, fragmentBefore) {
t.Fatal("composition mutated an input document")
}
composed.root.Mapping[0].Value.Mapping[0].Value.Mapping[0].Value.Text = "changed"
if !reflect.DeepEqual(base, baseBefore) || !reflect.DeepEqual(fragment, fragmentBefore) {
t.Fatal("composed document aliases an input document")
}
}
func TestComposeDocumentsRejectsEveryPresentNonMappingOverlap(t *testing.T) {
tests := []struct {
name string
left string
right string
path string
}{
{name: "equal scalar", left: "value: true\n", right: "value: true\n", path: "/value"},
{name: "different scalar", left: "value: true\n", right: "value: false\n", path: "/value"},
{name: "null", left: "value: null\n", right: "value: null\n", path: "/value"},
{name: "sequence", left: "value: [one]\n", right: "value: [two]\n", path: "/value"},
{name: "mapping and scalar", left: "value:\n nested: true\n", right: "value: text\n", path: "/value"},
{name: "scalar and mapping", left: "value: text\n", right: "value:\n nested: true\n", path: "/value"},
{name: "nested keyed entry", left: "items:\n shared:\n left: 1\n", right: "items:\n shared:\n left: 2\n", path: "/items/shared/left"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
left := parseCompositionDocument(t, "source-1", test.left)
right := parseCompositionDocument(t, "source-2", test.right)
_, diagnostics := composeDocuments(left, right)
assertCompositionConflict(t, diagnostics, test.path, []string{"source-1", "source-2"})
})
}
}
func TestComposeDocumentsReportsAllClaimantsAtExactLocations(t *testing.T) {
first := parseCompositionDocument(t, "source-1", "group:\n value: 1\n")
second := parseCompositionDocument(t, "source-2", "group:\n value: 2\n")
third := parseCompositionDocument(t, "source-3", "group:\n value: 3\n")
_, diagnostics := composeDocuments(first, second, third)
assertCompositionConflict(t, diagnostics, "/group/value", []string{"source-1", "source-2", "source-3"})
locations := append([]Location{*diagnostics[0].Primary}, diagnostics[0].Related...)
for _, location := range locations {
if location.Line != 2 || location.Column != 10 {
t.Fatalf("claim location = %#v, want line 2 column 10", location)
}
}
_, diagnostics = composeDocuments(
parseCompositionDocument(t, "source-1", "group:\n left: 1\n"),
parseCompositionDocument(t, "source-2", "group:\n right: 2\n"),
parseCompositionDocument(t, "source-3", "group: blocked\n"),
)
assertCompositionConflict(t, diagnostics, "/group", []string{"source-1", "source-2", "source-3"})
}
func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) {
empty, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", "{}\n"),
parseCompositionDocument(t, "source-2", "{}\n"),
)
if len(diagnostics) != 0 || len(empty.root.Mapping) != 0 || len(empty.origins[""].Locations) != 2 {
t.Fatalf("empty root composition = (%#v, %#v)", empty, diagnostics)
}
tests := []struct {
name string
left string
right string
}{
{name: "two empty mappings", left: "value: {}\n", right: "value: {}\n"},
{name: "empty then populated", left: "value: {}\n", right: "value: {nested: true}\n"},
{name: "populated then empty", left: "value: {nested: true}\n", right: "value: {}\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
composed, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", test.left),
parseCompositionDocument(t, "source-2", test.right),
)
if len(diagnostics) != 0 {
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
}
if origin := composed.origins["/value"]; len(origin.Locations) != 2 {
t.Fatalf("value origin = %#v, want both mapping locations", origin)
}
if test.name != "two empty mappings" {
if _, ok := documentAtPath(composed.root, Keys("value", "nested")); !ok {
t.Fatal("populated mapping contribution was not retained")
}
}
})
}
}
func TestComposeDocumentsRejectsInvalidBoundaries(t *testing.T) {
if composed, diagnostics := composeDocuments(); composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid {
t.Fatalf("empty composition = (%#v, %#v)", composed, diagnostics)
}
nonMapping := &document.Node{Kind: document.String, Location: document.Location{SourceID: "source-1", Line: 4, Column: 2}}
if composed, diagnostics := composeDocuments(nonMapping); composed != nil || len(diagnostics) != 1 || diagnostics[0].Primary == nil || diagnostics[0].Primary.Line != 4 {
t.Fatalf("non-mapping composition = (%#v, %#v)", composed, diagnostics)
}
}
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
t.Helper()
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics)
}
return parsed.document
}
func assertCompositionConflict(t *testing.T, diagnostics []Diagnostic, path string, sources []string) {
t.Helper()
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v, want one conflict", diagnostics)
}
diagnostic := diagnostics[0]
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != path || diagnostic.Primary == nil {
t.Fatalf("diagnostic = %#v, want compose conflict at %s", diagnostic, path)
}
locations := append([]Location{*diagnostic.Primary}, diagnostic.Related...)
gotSources := make([]string, len(locations))
for index, location := range locations {
gotSources[index] = location.SourceID
}
if !reflect.DeepEqual(gotSources, sources) {
t.Fatalf("claim sources = %#v, want %#v", gotSources, sources)
}
}

68
composition_fuzz_test.go Normal file
View File

@@ -0,0 +1,68 @@
package configkit
import (
"bytes"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
func FuzzCompositionAndOverlay(f *testing.F) {
for _, seed := range [][2]string{
{"{}\n", "{}\n"},
{"left: {value: one}\n", "right: [two]\n"},
{"shared: {left: one}\n", "shared: {right: two}\n"},
{"value: one\n", "value: two\n"},
} {
f.Add([]byte(seed[0]), []byte(seed[1]))
}
limits := yamladapter.Limits{Depth: 12, Nodes: 256, ParserDepth: 24}
f.Fuzz(func(t *testing.T, leftInput, rightInput []byte) {
if len(leftInput) > 4<<10 || len(rightInput) > 4<<10 {
return
}
left, leftIssues := yamladapter.Parse(bytes.NewReader(leftInput), "left", limits)
right, rightIssues := yamladapter.Parse(bytes.NewReader(rightInput), "right", limits)
if len(leftIssues) != 0 || len(rightIssues) != 0 {
return
}
leftBefore, rightBefore := document.Clone(left), document.Clone(right)
composed, diagnostics := composeDocuments(left, right)
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("composition mutated an input document")
}
if len(diagnostics) == 0 {
reversed, reversedDiagnostics := composeDocuments(right, left)
if len(reversedDiagnostics) != 0 {
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
}
if differences := compareDocumentTrees(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"})
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("composition result aliases an input document")
}
}
base, baseDiagnostics := composeDocuments(left)
if len(baseDiagnostics) != 0 {
t.Fatalf("single accepted mapping did not compose: %#v", baseDiagnostics)
}
overlaid, overlayDiagnostics := overlayDocuments(base, right)
if len(overlayDiagnostics) != 0 {
t.Fatalf("accepted mappings did not overlay: %#v", overlayDiagnostics)
}
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay mutated an input document")
}
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"})
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay result aliases an input document")
}
})
}

462
decode.go Normal file
View File

@@ -0,0 +1,462 @@
package configkit
import (
"encoding"
"math"
"reflect"
"regexp"
"strconv"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
var unsignedIntegerPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)$`)
type decodeIssue struct {
code Code
path Path
message string
location document.Location
}
type inputPresence struct {
null bool
location document.Location
}
type decodedConfiguration struct {
value reflect.Value
presence map[string]inputPresence
origins map[string]Origin
baseline *document.Node
final *document.Node
}
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
}
baseline, projectionIssue := projectConfiguration(schema, defaults, defaultProjectionLimits())
if projectionIssue != nil {
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(projectionIssue)}
}
working, cloneIssue := cloneConfiguration(schema, defaults, defaultProjectionLimits())
if cloneIssue != nil {
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(cloneIssue)}
}
presence := make(map[string]inputPresence)
if input != nil {
collectPresence(input, Path{}, presence)
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema)
if len(issues) != 0 {
return decodedConfiguration{}, issues
}
working = decoded
}
final, projectionIssue := projectConfiguration(schema, working, defaultProjectionLimits())
if projectionIssue != nil {
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(projectionIssue)}
}
origins := make(map[string]Origin)
collectOrigins(final, Path{}, presence, origins)
return decodedConfiguration{
value: working,
presence: presence,
origins: origins,
baseline: baseline,
final: final,
}, nil
}
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind == document.Null {
switch descriptor.kind {
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
return reflect.Zero(descriptor.typeOf), nil
case descriptorSecret:
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeSecretRequired, path, node, "required secret reference cannot be null")}
default:
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeNull, path, node, "null is not allowed for this configuration type")}
}
}
if descriptor.kind == descriptorPointer {
var elementBase reflect.Value
if base.IsNil() {
elementBase = reflect.Zero(descriptor.typeOf.Elem())
} else {
elementBase = base.Elem()
}
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema)
if len(issues) != 0 {
return reflect.Value{}, issues
}
result := reflect.New(descriptor.typeOf.Elem())
result.Elem().Set(decoded)
return result, nil
}
switch descriptor.kind {
case descriptorStruct:
return decodeStruct(node, base, descriptor, path, schema)
case descriptorMap:
return decodeMap(node, base, descriptor, path, schema)
case descriptorSlice:
return decodeSlice(node, descriptor, path, schema)
case descriptorArray:
return decodeArray(node, descriptor, path, schema)
case descriptorDynamic:
return decodeDynamic(node, base, path, schema)
case descriptorString:
if node.Kind != document.String {
return typeMismatch(path, node, "string")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetString(node.Text)
return result, nil
case descriptorBoolean:
if node.Kind != document.Boolean {
return typeMismatch(path, node, "boolean")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetBool(node.Text == "true")
return result, nil
case descriptorSigned:
if node.Kind != document.Number || !integerPattern.MatchString(node.Text) {
return typeMismatch(path, node, "integer")
}
value, err := strconv.ParseInt(node.Text, 10, descriptor.typeOf.Bits())
if err != nil {
return rangeFailure(path, node, "integer")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetInt(value)
return result, nil
case descriptorUnsigned:
if node.Kind != document.Number || !unsignedIntegerPattern.MatchString(node.Text) {
return typeMismatch(path, node, "unsigned integer")
}
value, err := strconv.ParseUint(node.Text, 10, descriptor.typeOf.Bits())
if err != nil {
return rangeFailure(path, node, "unsigned integer")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetUint(value)
return result, nil
case descriptorFloat:
if node.Kind != document.Number {
return typeMismatch(path, node, "number")
}
value, err := strconv.ParseFloat(node.Text, descriptor.typeOf.Bits())
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
return rangeFailure(path, node, "number")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetFloat(value)
return result, nil
case descriptorDuration:
if node.Kind != document.String {
return typeMismatch(path, node, "duration string")
}
value, err := time.ParseDuration(node.Text)
if err != nil {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeDuration, path, node, "duration string is invalid")}
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetInt(int64(value))
return result, nil
case descriptorSecret:
if node.Kind != document.String {
return typeMismatch(path, node, "secret environment name")
}
return reflect.ValueOf(SecretEnv(node.Text)), nil
case descriptorText:
if node.Kind != document.String {
return typeMismatch(path, node, "text string")
}
result := reflect.New(descriptor.typeOf).Elem()
unmarshaler := result.Addr().Interface().(encoding.TextUnmarshaler)
if err := unmarshaler.UnmarshalText([]byte(node.Text)); err != nil {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeText, path, node, "text value is invalid for its configuration type")}
}
return result, nil
default:
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeSchemaUnsupportedType, path, node, "configuration type is not supported")}
}
}
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind != document.Mapping {
return typeMismatch(path, node, "mapping")
}
result := reflect.New(descriptor.typeOf).Elem()
result.Set(base)
var issues []decodeIssue
for _, entry := range node.Mapping {
childPath := path.Key(entry.Key)
if position, ok := descriptor.fieldByName[entry.Key]; ok {
field := descriptor.fields[position]
target, ok := fieldForDecode(result, field.index, true)
if !ok {
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
continue
}
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema)
if len(fieldIssues) != 0 {
issues = append(issues, fieldIssues...)
continue
}
target.Set(decoded)
continue
}
if descriptor.inlineMap != nil {
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema); issue != nil {
issues = append(issues, issue...)
}
continue
}
issues = append(issues, decodeIssue{code: CodeDecodeUnknownField, path: childPath, message: "configuration field is not declared", location: entry.KeyLocation})
}
return result, issues
}
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor) []decodeIssue {
mapValue, ok := fieldForDecode(target, field.index, true)
if !ok || mapValue.Kind() != reflect.Map {
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
}
if mapValue.IsNil() {
mapValue.Set(reflect.MakeMap(mapValue.Type()))
}
key := reflect.New(mapValue.Type().Key()).Elem()
key.SetString(entry.Key)
base := mapValue.MapIndex(key)
if !base.IsValid() {
base = reflect.Zero(mapValue.Type().Elem())
}
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema)
if len(issues) == 0 {
mapValue.SetMapIndex(key, decoded)
}
return issues
}
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind != document.Mapping {
return typeMismatch(path, node, "mapping")
}
result := base
if result.IsNil() {
result = reflect.MakeMap(descriptor.typeOf)
}
var issues []decodeIssue
for _, entry := range node.Mapping {
key := reflect.New(descriptor.typeOf.Key()).Elem()
key.SetString(entry.Key)
entryBase := result.MapIndex(key)
if !entryBase.IsValid() {
entryBase = reflect.Zero(descriptor.typeOf.Elem())
}
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema)
if len(entryIssues) != 0 {
issues = append(issues, entryIssues...)
continue
}
result.SetMapIndex(key, decoded)
}
return result, issues
}
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind != document.Sequence {
return typeMismatch(path, node, "sequence")
}
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
var issues []decodeIssue
for i, child := range node.Sequence {
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
}
result.Index(i).Set(decoded)
}
return result, issues
}
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind != document.Sequence {
return typeMismatch(path, node, "sequence")
}
if len(node.Sequence) != descriptor.length {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeArrayLength, path, node, "configuration array has the wrong length")}
}
result := reflect.New(descriptor.typeOf).Elem()
var issues []decodeIssue
for i, child := range node.Sequence {
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
}
result.Index(i).Set(decoded)
}
return result, issues
}
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
concrete := base.Elem()
operation := valueOperation{schema: schema}
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
if issue == nil && acceptsMapping(descriptor) {
decoded, issues := decodeValue(node, concrete, descriptor, path, schema)
if len(issues) != 0 {
return reflect.Value{}, issues
}
result := reflect.New(base.Type()).Elem()
result.Set(decoded)
return result, nil
}
}
var value any
switch node.Kind {
case document.Null:
return reflect.Zero(base.Type()), nil
case document.String:
value = node.Text
case document.Boolean:
value = node.Text == "true"
case document.Number:
if integerPattern.MatchString(node.Text) {
parsed, err := strconv.ParseInt(node.Text, 10, 64)
if err != nil {
return rangeFailure(path, node, "dynamic integer")
}
value = parsed
} else {
parsed, err := strconv.ParseFloat(node.Text, 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
return rangeFailure(path, node, "dynamic number")
}
value = parsed
}
case document.Sequence:
sequence := make([]any, len(node.Sequence))
var issues []decodeIssue
for i, child := range node.Sequence {
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
}
sequence[i] = decoded.Interface()
}
if len(issues) != 0 {
return reflect.Value{}, issues
}
value = sequence
case document.Mapping:
mapping := make(map[string]any, len(node.Mapping))
var issues []decodeIssue
for _, entry := range node.Mapping {
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
}
mapping[entry.Key] = decoded.Interface()
}
if len(issues) != 0 {
return reflect.Value{}, issues
}
value = mapping
default:
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "dynamic configuration value has an unsupported kind")}
}
result := reflect.New(base.Type()).Elem()
if value != nil {
result.Set(reflect.ValueOf(value))
}
return result, nil
}
func acceptsMapping(descriptor *typeDescriptor) bool {
for descriptor != nil && descriptor.kind == descriptorPointer {
descriptor = descriptor.element
}
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic)
}
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
current := root
for position, fieldIndex := range index {
if current.Kind() != reflect.Struct {
return reflect.Value{}, false
}
current = current.Field(fieldIndex)
if position == len(index)-1 {
return current, current.CanSet()
}
for current.Kind() == reflect.Pointer {
if current.IsNil() {
if !allocate || !current.CanSet() {
return reflect.Value{}, false
}
current.Set(reflect.New(current.Type().Elem()))
}
current = current.Elem()
}
}
return reflect.Value{}, false
}
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectPresence(entry.Value, path.Key(entry.Key), presence)
}
case document.Sequence:
for i, child := range node.Sequence {
collectPresence(child, path.Index(i), presence)
}
}
}
func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) {
if input, ok := presence[path.String()]; ok {
origins[path.String()] = Origin{Layer: LayerBase, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}}
} else {
origins[path.String()] = Origin{Layer: LayerDefault}
}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectOrigins(entry.Value, path.Key(entry.Key), presence, origins)
}
case document.Sequence:
for i, child := range node.Sequence {
collectOrigins(child, path.Index(i), presence, origins)
}
}
}
func decodeIssueFromProjection(issue *projectionIssue) decodeIssue {
return decodeIssue{code: issue.code, path: issue.path.clone(), message: issue.message}
}
func newDecodeIssue(code Code, path Path, node *document.Node, message string) decodeIssue {
return decodeIssue{code: code, path: path.clone(), message: message, location: node.Location}
}
func typeMismatch(path Path, node *document.Node, expected string) (reflect.Value, []decodeIssue) {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration value must be a "+expected)}
}
func rangeFailure(path Path, node *document.Node, expected string) (reflect.Value, []decodeIssue) {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeRange, path, node, expected+" is outside the supported range")}
}

312
decode_test.go Normal file
View File

@@ -0,0 +1,312 @@
package configkit
import (
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type namedSmall int8
type validatedText string
func (value validatedText) MarshalText() ([]byte, error) {
return []byte(value), nil
}
func (value *validatedText) UnmarshalText(text []byte) error {
if string(text) == "bad-secret-canary" {
return errors.New("CANARY_TEXT_ERROR: bad-secret-canary")
}
*value = validatedText(text)
return nil
}
func TestDecodeAppliesInputOverDetachedDefaults(t *testing.T) {
t.Parallel()
type nested struct {
A string `yaml:"a"`
B string `yaml:"b"`
}
type config struct {
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
Label string `yaml:"label"`
Nested nested `yaml:"nested"`
EmptyMapping nested `yaml:"empty_mapping"`
Objects map[string]nested `yaml:"objects"`
Sequence []string `yaml:"sequence"`
Replacement []string `yaml:"replacement"`
Pointer *nested `yaml:"pointer"`
NilPointer *nested `yaml:"nil_pointer"`
Nullable map[string]string `yaml:"nullable"`
Dynamic any `yaml:"dynamic"`
DynamicShape any `yaml:"dynamic_shape"`
DynamicNull any `yaml:"dynamic_null"`
Fixed [2]int `yaml:"fixed"`
Small namedSmall `yaml:"small"`
Delay time.Duration `yaml:"delay"`
Custom validatedText `yaml:"custom"`
}
defaults := config{
Enabled: true,
Workers: 4,
Label: "default",
Nested: nested{A: "keep", B: "before"},
EmptyMapping: nested{A: "keep-empty", B: "keep-empty"},
Objects: map[string]nested{
"existing": {A: "keep-map", B: "before"},
},
Sequence: []string{"default"},
Replacement: []string{"default"},
Pointer: &nested{A: "keep-pointer", B: "before"},
Nullable: map[string]string{"default": "value"},
Dynamic: map[string]any{"keep": "default", "count": int64(4)},
DynamicShape: nested{A: "keep-dynamic", B: "before"},
DynamicNull: "default",
Fixed: [2]int{1, 2},
Small: 7,
Delay: time.Second,
Custom: "default",
}
input := `
enabled: false
workers: 0
label: ""
nested: {b: changed}
empty_mapping: {}
objects:
existing: {b: changed}
new: {b: added}
sequence: []
replacement: [one, two]
pointer: {b: changed}
nil_pointer: {b: added}
nullable: null
dynamic: {count: 0, fraction: 1.5}
dynamic_shape: {b: changed}
dynamic_null: null
fixed: [3, 4]
small: -8
delay: 1.5s
custom: accepted
`
schema := mustSchemaFor[config](t)
documentRoot := parseDocumentForDecode(t, input)
decoded, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), documentRoot)
if len(issues) != 0 {
t.Fatalf("decode issues = %#v", issues)
}
got := decoded.value.Interface().(config)
if got.Enabled || got.Workers != 0 || got.Label != "" {
t.Fatalf("explicit zero values lost: %#v", got)
}
if got.Nested != (nested{A: "keep", B: "changed"}) || got.EmptyMapping != defaults.EmptyMapping {
t.Fatalf("mapping defaults = %#v / %#v", got.Nested, got.EmptyMapping)
}
if got.Objects["existing"] != (nested{A: "keep-map", B: "changed"}) || got.Objects["new"] != (nested{B: "added"}) {
t.Fatalf("map defaults = %#v", got.Objects)
}
if got.Sequence == nil || len(got.Sequence) != 0 || !reflect.DeepEqual(got.Replacement, []string{"one", "two"}) {
t.Fatalf("sequence replacement = %#v / %#v", got.Sequence, got.Replacement)
}
if got.Pointer == defaults.Pointer || *got.Pointer != (nested{A: "keep-pointer", B: "changed"}) || got.NilPointer == nil || *got.NilPointer != (nested{B: "added"}) {
t.Fatalf("pointer defaults = %#v / %#v", got.Pointer, got.NilPointer)
}
if got.Nullable != nil || got.DynamicNull != nil {
t.Fatalf("null values = %#v / %#v", got.Nullable, got.DynamicNull)
}
dynamic := got.Dynamic.(map[string]any)
if dynamic["keep"] != "default" || dynamic["count"] != int64(0) || dynamic["fraction"] != float64(1.5) {
t.Fatalf("dynamic mapping = %#v", dynamic)
}
if got.DynamicShape != (nested{A: "keep-dynamic", B: "changed"}) {
t.Fatalf("dynamic concrete mapping = %#v", got.DynamicShape)
}
if got.Fixed != [2]int{3, 4} || got.Small != -8 || got.Delay != 1500*time.Millisecond || got.Custom != "accepted" {
t.Fatalf("scalar decoding = %#v", got)
}
for _, path := range []Path{Keys(), Keys("enabled"), Keys("nested"), Keys("nested", "b"), Keys("empty_mapping")} {
if _, ok := decoded.presence[path.String()]; !ok {
t.Errorf("explicit path %q missing", path.String())
}
}
if _, ok := decoded.presence[Keys("nested", "a").String()]; ok {
t.Fatal("parent mapping made omitted child explicit")
}
if decoded.origins[Keys("nested", "a").String()].Layer != LayerDefault || decoded.origins[Keys("nested", "b").String()].Layer != LayerBase {
t.Fatalf("origins = %#v", decoded.origins)
}
if decoded.origins[Keys("nested", "b").String()].Locations[0].Line == 0 {
t.Fatal("input origin lost source coordinates")
}
defaults.Objects["existing"] = nested{A: "mutated"}
defaults.Pointer.B = "mutated"
defaults.Sequence[0] = "mutated"
if got.Objects["existing"].A != "keep-map" || got.Pointer.B != "changed" || len(got.Sequence) != 0 {
t.Fatal("decoded value aliases defaults")
}
got.Pointer.B = "caller mutation"
if mappingValue(t, mappingValue(t, decoded.final, "pointer"), "b").Text != "changed" {
t.Fatal("final snapshot aliases returned typed value")
}
if mappingValue(t, mappingValue(t, decoded.baseline, "nested"), "b").Text != "before" {
t.Fatal("baseline snapshot was mutated by decode")
}
}
func TestDecodeDefaultsOnlyAndRepeatedResultsAreIndependent(t *testing.T) {
t.Parallel()
type config struct {
Values map[string][]string `yaml:"values"`
}
defaults := config{Values: map[string][]string{"one": {"a"}}}
schema := mustSchemaFor[config](t)
first, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), nil)
if len(issues) != 0 {
t.Fatal(issues)
}
second, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), nil)
if len(issues) != 0 {
t.Fatal(issues)
}
firstValue := first.value.Interface().(config)
secondValue := second.value.Interface().(config)
firstValue.Values["one"][0] = "changed"
if secondValue.Values["one"][0] != "a" || defaults.Values["one"][0] != "a" {
t.Fatal("repeated defaults-only results share aliases")
}
if len(first.presence) != 0 || first.origins[Keys("values", "one").String()].Layer != LayerDefault {
t.Fatalf("defaults-only metadata = %#v / %#v", first.presence, first.origins)
}
}
func TestDecodeCollectsIndependentTypeAndShapeFailures(t *testing.T) {
t.Parallel()
type nested struct {
Known int `yaml:"known"`
}
type config struct {
Small int8 `yaml:"small"`
Unsigned uint8 `yaml:"unsigned"`
Number float32 `yaml:"number"`
Text string `yaml:"text"`
Flag bool `yaml:"flag"`
Delay time.Duration `yaml:"delay"`
Fixed [2]int `yaml:"fixed"`
Nested nested `yaml:"nested"`
Custom validatedText `yaml:"custom"`
Dynamic any `yaml:"dynamic"`
}
input := `
small: 128
unsigned: -1
number: 1e999
text: 3
flag: "true"
delay: 10
fixed: [1]
nested: {first_unknown: 1, second_unknown: 2}
custom: bad-secret-canary
dynamic: 9223372036854775808
`
schema := mustSchemaFor[config](t)
_, issues := decodeConfiguration(schema, reflect.ValueOf(config{Custom: "ok"}), parseDocumentForDecode(t, input))
if len(issues) != 11 {
t.Fatalf("issues = %d: %#v", len(issues), issues)
}
want := map[string]Code{
"/small": CodeDecodeRange,
"/unsigned": CodeDecodeType,
"/number": CodeDecodeRange,
"/text": CodeDecodeType,
"/flag": CodeDecodeType,
"/delay": CodeDecodeType,
"/fixed": CodeDecodeArrayLength,
"/nested/first_unknown": CodeDecodeUnknownField,
"/nested/second_unknown": CodeDecodeUnknownField,
"/custom": CodeDecodeText,
"/dynamic": CodeDecodeRange,
}
for _, issue := range issues {
if wantCode, ok := want[issue.path.String()]; !ok || wantCode != issue.code {
t.Errorf("unexpected issue %#v", issue)
}
if issue.location.Line == 0 {
t.Errorf("issue lost input location: %#v", issue)
}
if strings.Contains(issue.message, "CANARY") || strings.Contains(issue.message, "bad-secret") {
t.Errorf("issue exposed value: %#v", issue)
}
}
}
func TestDecodeNullabilityAndNestedMapStrictness(t *testing.T) {
t.Parallel()
type nested struct {
Value int `yaml:"value"`
}
type config struct {
Scalar int `yaml:"scalar"`
Object nested `yaml:"object"`
Array [1]int `yaml:"array"`
Map map[string]nested `yaml:"map"`
}
input := "scalar: null\nobject: null\narray: null\nmap:\n item: {unknown: 1}\n"
_, issues := decodeConfiguration(mustSchemaFor[config](t), reflect.ValueOf(config{}), parseDocumentForDecode(t, input))
want := map[string]Code{
"/scalar": CodeDecodeNull,
"/object": CodeDecodeNull,
"/array": CodeDecodeNull,
"/map/item/unknown": CodeDecodeUnknownField,
}
if len(issues) != len(want) {
t.Fatalf("issues = %#v", issues)
}
for _, issue := range issues {
if want[issue.path.String()] != issue.code {
t.Errorf("issue = %#v", issue)
}
}
}
func TestDecodeRejectsInvalidDefaultsBeforeApplyingInput(t *testing.T) {
t.Parallel()
type config struct {
Name string `yaml:"name"`
Extras map[string]any `yaml:",inline"`
Float float64 `yaml:"float"`
}
schema := mustSchemaFor[config](t)
for name, defaults := range map[string]config{
"inline collision": {Name: "declared", Extras: map[string]any{"name": "duplicate"}},
"non-finite": {Float: math.NaN()},
} {
t.Run(name, func(t *testing.T) {
if _, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), parseDocumentForDecode(t, "{}\n")); len(issues) != 1 || !strings.HasPrefix(string(issues[0].code), "schema.") {
t.Fatalf("issues = %#v", issues)
}
})
}
}
func parseDocumentForDecode(t *testing.T, input string) *document.Node {
t.Helper()
parsed, diagnostics := parseSource(Bytes("decode.yml", []byte(input)), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics)
}
return parsed.document
}

239
diagnostic.go Normal file
View File

@@ -0,0 +1,239 @@
package configkit
import (
"cmp"
"fmt"
"io"
"slices"
"strconv"
"strings"
"unicode"
)
// LoadError reports safe structured diagnostics for a failed configuration load.
// It intentionally does not expose or unwrap parser and application errors.
type LoadError struct {
diagnostics []Diagnostic
sources []SourceInfo
}
// Error implements error without rendering diagnostic messages or input values.
func (e *LoadError) Error() string {
if e == nil {
return "configkit: configuration load failed"
}
count := 0
for _, diagnostic := range e.diagnostics {
if diagnostic.Severity == SeverityError {
count++
}
}
if count == 1 {
return "configkit: configuration load failed with 1 error"
}
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
}
// Diagnostics returns a deep copy of the error's canonical diagnostics.
func (e *LoadError) Diagnostics() []Diagnostic {
if e == nil {
return nil
}
return cloneDiagnostics(e.diagnostics)
}
// Sources returns a deep copy of sources discovered before loading failed.
func (e *LoadError) Sources() []SourceInfo {
if e == nil {
return nil
}
return cloneSourceInfos(e.sources)
}
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
canonical := canonicalDiagnostics(diagnostics)
for _, diagnostic := range canonical {
if err := writeDiagnostic(output, diagnostic); err != nil {
return err
}
}
return nil
}
type diagnosticAccumulator struct {
diagnostics []Diagnostic
sources []SourceInfo
}
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
for _, diagnostic := range diagnostics {
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
}
}
func (a *diagnosticAccumulator) loadError() *LoadError {
return &LoadError{
diagnostics: canonicalDiagnostics(a.diagnostics),
sources: cloneSourceInfos(a.sources),
}
}
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
canonical := cloneDiagnostics(diagnostics)
for i := range canonical {
slices.SortFunc(canonical[i].Related, compareLocation)
}
slices.SortFunc(canonical, compareDiagnostic)
return canonical
}
func compareDiagnostic(left, right Diagnostic) int {
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
return order
}
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
return order
}
if order := cmp.Compare(left.Code, right.Code); order != 0 {
return order
}
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
return order
}
if order := cmp.Compare(left.Message, right.Message); order != 0 {
return order
}
return compareLocations(left.Related, right.Related)
}
func compareOptionalLocation(left, right *Location) int {
if left == nil && right == nil {
return 0
}
if left == nil {
return -1
}
if right == nil {
return 1
}
return compareLocation(*left, *right)
}
func compareLocations(left, right []Location) int {
for i := 0; i < min(len(left), len(right)); i++ {
if order := compareLocation(left[i], right[i]); order != 0 {
return order
}
}
return cmp.Compare(len(left), len(right))
}
func compareLocation(left, right Location) int {
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
return order
}
if order := cmp.Compare(left.Line, right.Line); order != 0 {
return order
}
return cmp.Compare(left.Column, right.Column)
}
func compareSourceID(left, right string) int {
leftOrder, leftOK := sourceSequence(left)
rightOrder, rightOK := sourceSequence(right)
if leftOK && rightOK {
return cmp.Compare(leftOrder, rightOrder)
}
return cmp.Compare(left, right)
}
func sourceSequence(sourceID string) (int, bool) {
const prefix = "source-"
if !strings.HasPrefix(sourceID, prefix) {
return 0, false
}
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
if err != nil || value < 1 {
return 0, false
}
return value, true
}
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
severity := diagnostic.Severity
if severity == "" {
severity = SeverityError
}
path := diagnostic.Path.String()
if path == "" {
path = "<root>"
}
var b strings.Builder
b.WriteString(escapeControls(string(severity)))
if diagnostic.Code != "" {
b.WriteString(" [")
b.WriteString(escapeControls(string(diagnostic.Code)))
b.WriteByte(']')
}
b.WriteByte(' ')
b.WriteString(escapeControls(path))
if diagnostic.Primary != nil {
b.WriteString(" (")
writeLocationText(&b, *diagnostic.Primary)
b.WriteByte(')')
}
if diagnostic.Message != "" {
b.WriteString(": ")
b.WriteString(escapeControls(diagnostic.Message))
}
b.WriteByte('\n')
for _, related := range diagnostic.Related {
b.WriteString(" related: ")
writeLocationText(&b, related)
b.WriteByte('\n')
}
return writeString(output, b.String())
}
func writeLocationText(output *strings.Builder, location Location) {
label := location.SourceID
if label == "" {
label = "<synthetic>"
}
output.WriteString(escapeControls(label))
if location.Line > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Line))
if location.Column > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Column))
}
}
}
func writeString(output io.Writer, value string) error {
written, err := io.WriteString(output, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}
func escapeControls(value string) string {
var b strings.Builder
for _, r := range value {
if unicode.IsControl(r) {
quoted := strconv.QuoteRune(r)
b.WriteString(quoted[1 : len(quoted)-1])
continue
}
b.WriteRune(r)
}
return b.String()
}

143
diagnostic_test.go Normal file
View File

@@ -0,0 +1,143 @@
package configkit
import (
"bytes"
"errors"
"io"
"strings"
"testing"
)
func TestDiagnosticsAreCanonicalAndDetached(t *testing.T) {
t.Parallel()
diagnostics := []Diagnostic{
{
Code: "decode.type",
Severity: SeverityError,
Path: Keys("later"),
Message: "later",
Primary: &Location{SourceID: "source-10", Line: 1, Column: 1},
},
{
Code: "schema.invalid",
Severity: SeverityError,
Path: Keys("root"),
Message: "source-less",
},
{
Code: "decode.type",
Severity: SeverityError,
Path: Keys("earlier"),
Message: "earlier",
Primary: &Location{SourceID: "source-2", Line: 3, Column: 4},
Related: []Location{
{SourceID: "source-10", Line: 1, Column: 1},
{SourceID: "source-2", Line: 9, Column: 1},
},
},
}
var forward bytes.Buffer
if err := WriteDiagnostics(&forward, diagnostics); err != nil {
t.Fatal(err)
}
reversed := []Diagnostic{diagnostics[2], diagnostics[1], diagnostics[0]}
var backward bytes.Buffer
if err := WriteDiagnostics(&backward, reversed); err != nil {
t.Fatal(err)
}
if forward.String() != backward.String() {
t.Fatalf("rendering depends on submission order:\n%s\n---\n%s", forward.String(), backward.String())
}
lines := strings.Split(forward.String(), "\n")
if !strings.Contains(lines[0], "source-less") || !strings.Contains(lines[1], "source-2:3:4") {
t.Fatalf("unexpected canonical order:\n%s", forward.String())
}
if !strings.Contains(lines[2], "source-2:9:1") || !strings.Contains(lines[3], "source-10:1:1") {
t.Fatalf("related locations not canonical:\n%s", forward.String())
}
accumulator := diagnosticAccumulator{
sources: []SourceInfo{{
ID: "source-1",
Label: "config.yml",
ImportedFrom: &Location{SourceID: "source-0", Line: 1},
}},
}
accumulator.add(diagnostics...)
loadErr := accumulator.loadError()
var found *LoadError
if !errors.As(error(loadErr), &found) || found != loadErr {
t.Fatal("errors.As did not find LoadError")
}
first := loadErr.Diagnostics()
first[1].Primary.SourceID = "mutated"
first[1].Path.segments[0].key = "mutated"
first[1].Related[0].SourceID = "mutated"
second := loadErr.Diagnostics()
if second[1].Primary.SourceID != "source-2" || second[1].Path.String() != "/earlier" || second[1].Related[0].SourceID != "source-2" {
t.Fatal("Diagnostics returned aliased records")
}
sources := loadErr.Sources()
sources[0].ImportedFrom.SourceID = "mutated"
if loadErr.Sources()[0].ImportedFrom.SourceID != "source-0" {
t.Fatal("Sources returned aliased records")
}
if got := loadErr.Error(); got != "configkit: configuration load failed with 3 errors" {
t.Fatalf("Error() = %q", got)
}
}
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
t.Parallel()
diagnostic := Diagnostic{
Code: "validate.error",
Severity: SeverityError,
Path: Keys("line\nbreak"),
Message: "message\r\nnext",
Primary: &Location{SourceID: "source\tlabel", Line: 1},
}
var output bytes.Buffer
if err := WriteDiagnostics(&output, []Diagnostic{diagnostic}); err != nil {
t.Fatal(err)
}
got := output.String()
if strings.Count(got, "\n") != 1 {
t.Fatalf("control characters created output lines: %q", got)
}
for _, escaped := range []string{`line\nbreak`, `message\r\nnext`, `source\tlabel`} {
if !strings.Contains(got, escaped) {
t.Errorf("output %q does not contain %q", got, escaped)
}
}
}
func TestWriteDiagnosticsPropagatesWriterFailures(t *testing.T) {
t.Parallel()
diagnostics := []Diagnostic{{Severity: SeverityError, Message: "safe"}}
want := errors.New("write failed")
if err := WriteDiagnostics(errorWriter{err: want}, diagnostics); !errors.Is(err, want) {
t.Fatalf("error writer returned %v", err)
}
if err := WriteDiagnostics(shortWriter{}, diagnostics); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer returned %v", err)
}
}
type errorWriter struct {
err error
}
func (w errorWriter) Write(p []byte) (int, error) {
return min(3, len(p)), w.err
}
type shortWriter struct{}
func (shortWriter) Write(p []byte) (int, error) {
return max(0, len(p)-1), nil
}

10
doc.go Normal file
View File

@@ -0,0 +1,10 @@
// Package configkit provides deterministic, application-owned configuration
// loading for Go programs.
//
// Load currently supports one file, byte, reader, or explicit no-input source with
// strict YAML, typed defaults, schema-version checks, detached metadata, safe
// diagnostics, recorded application resolution, read-only semantic validation, and
// structured load errors. Composition, profiles, overrides, secrets, and effective
// rendering are not available yet. The configuration contract is documented in
// docs/config.md.
package configkit

View File

@@ -20,8 +20,8 @@ Use distinct strict-composition and intentional-overlay operations. Composition
rejects conflicting definitions; overlay provides explicit precedence. Keep the
operations opinionated rather than adding arbitrary per-field merge strategies.
The [architecture policy](../policy/architecture.md) owns the governing boundary;
the [target design](../roadmap/initial.md#composition-and-overlay) owns the planned
operation table until its current contract lands.
the [current configuration contract](../config.md#strict-document-composition) owns
the operation table and selected-profile overlay behavior.
## Alternatives considered

View File

@@ -20,7 +20,8 @@ ConfigKit defines its supported YAML policy and keeps parser integration private
Application types and diagnostics must not require parser-specific public types.
Use a small internal boundary rather than a general parser/provider framework.
The [architecture policy](../policy/architecture.md) owns this constraint; the
[target design](../roadmap/initial.md#yaml-and-scalar-policy) owns planned syntax.
[current YAML document](../config.md#yaml-documents) and
[scalar](../config.md#scalars) contracts own the implemented syntax.
## Alternatives considered

View File

@@ -22,8 +22,9 @@ values. Keep application defaults and normalization application-owned, but recor
resolution changes so final inspection and metadata describe the actual resolved
snapshot. Do not promise language-enforced immutability of exported Go values.
The [architecture policy](../policy/architecture.md) owns these invariants; the
[target design](../roadmap/initial.md#recorded-resolution-and-validation) owns the
planned recording mechanism and ownership semantics.
[current resolution contract](../config.md#recorded-application-resolution) and
[ownership contract](../config.md#typed-projection-and-ownership) own the implemented
recording mechanism and ownership semantics.
## Alternatives considered

View File

@@ -21,8 +21,9 @@ Existing environment variables win even when empty. Report skipped files and
partial failures without values. Keep bootstrap provenance separate from later
lookup provenance rather than imply an unchanged environment between calls.
General environment-variable configuration overlays are not on the roadmap.
The [feature roadmap](../roadmap/initial.md#secret-references-and-explicit-bootstrap)
owns the unimplemented contract; stages remain in the implementation plan.
The [current reference contract](../config.md#typed-secret-references) and
[bootstrap contract](../config.md#explicit-secrets-directory-bootstrap) own the
implemented behavior.
## Alternatives considered

View File

@@ -19,8 +19,9 @@ node tree before typed decoding, retaining the existing parser adapter boundary.
Recorded domain resolution follows input selection. Provide safe structured
historical values and an explanation renderer in the initial release, building
on ADR 0003's presence and audited resolution decision. Applications own commands.
The [feature roadmap](../roadmap/initial.md#cli-overrides) owns the exact override
contract and [explanation contract](../roadmap/initial.md#historical-value-explanation).
The [current override contract](../config.md#explicit-cli-overrides) and
[explanation contract](../config.md#historical-value-explanation) own the implemented
behavior.
## Alternatives considered

686
docs/config.md Normal file
View File

@@ -0,0 +1,686 @@
# Configuration Input Contract
This document owns ConfigKit's currently implemented source-input, YAML syntax,
schema, typed-decoding, loading, metadata, redaction, rendering, and ownership
contracts. The parser, owned document representation, and decoder remain private
behind the public loading lifecycle.
## Sources
ConfigKit exposes opaque sources through these constructors:
| Constructor | Current contract |
| --- | --- |
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
| `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. |
The zero `Source` is invalid. Constructor argument failures are retained inside the
opaque source and become safe structured diagnostics when the source is consumed.
File sources can observe file changes between consumptions; byte sources cannot.
Each source is limited to 8 MiB. A load using imports or an active profile is limited
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
1,000,000 visited directory entries, including nonmatches. Reads request at most the
remaining allowance plus one byte so excess data is rejected rather than silently
truncated. Data returned together with a read error is not accepted as partial input.
## Single-source loading
`Load[T](source, options...)` accepts zero or one `Options[T]`. The configuration
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.
The current lifecycle is:
```text
validate options → parse the selected source → traverse/compose enabled imports
→ build schema → check the optional application version → apply a selected profile
→ apply explicit CLI overrides → create and clone defaults → strictly decode
→ recorded application resolution
→ resolve typed secret references → semantic validation → classify secrets
→ snapshot configuration and metadata
```
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
ID `source-1`; enabled imports continue in depth-first order as `source-2` onward.
`NoSource` skips parsing, supplies an absent version to a configured version checker,
contributes no source, and continues through defaults, decode, and projection. A
missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
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
disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating
the returned configuration or inspection records cannot alter ConfigKit's private
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
frozen redacted projection.
### Application schema versions
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
decoding, the checker receives a `Version` preserving absent, explicit null, or
string input. Any other input kind is a `version.type` error and the checker does not
run. ConfigKit does not interpret the text or impose a universal version path.
The checker reports safe, value-free errors and warnings through `Validation.Error`
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
nearest input locations. Reported errors fail loading. If the checker returns an
error without reporting one, ConfigKit emits a generic `version.hook_failed`
diagnostic and suppresses the returned error text. Warnings alone are retained on a
successful result. Callback panics remain programming failures.
### Layered metadata
`Result.Metadata` describes every final canonical path. 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
default baseline followed by exact base or profile definitions, including
equal-valued input. Profile replacement removes displaced descendants from effective
metadata while retaining their base definition and the parent replacement operation
in history. All metadata queries and returned nested records are detached.
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;
the descendant can therefore retain history after `Lookup` stops reporting it as
effective. Equal-valued input is still an explicit base definition, while a derived
value for absent input remains non-explicit.
A mapping assembled from the selected file and imports has one composite base origin
whose locations retain source traversal order. A mapping also visited by a profile
has a profile-layer composite origin with the lower contributing locations followed
by the profile location; an empty mapping is still an explicit container even though
it contributes no children. Resolution origins are synthetic: they have layer
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
operation. Reasons and dependency paths retain first occurrence order with duplicates
removed. Source records remain ordered as selected source, depth-first imports, the
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
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.
Recorded resolution dependencies propagate sensitivity to their derived target.
Propagation reaches a fixed point, so record/map iteration order does not affect the
result and dependency cycles terminate. A dependency on a classified descendant or
on a container that contains one is treated conservatively as sensitive. Applications
must classify secret-producing transformations whose dependencies are not recorded.
`Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted
YAML. Mappings sort by decoded key string, sequences retain order, indentation is two
spaces with noncompact sequence indentation, line endings are LF, the document has no
start or end marker, and output ends with a newline. Nil pointers/maps/slices remain
`null`; non-nil empty maps and sequences remain `{}` and `[]`. A secret root is
replaced by the quoted string `'[REDACTED]'`. Redacted output is intended for
inspection and is not promised to be reloadable.
Rendering uses only a safe adapter tree built from the already completed canonical
typed projection. It does not invoke application YAML marshal hooks or text codecs;
all text-codec projection has succeeded before `Load` returns. Caller mutation of
`Result.Config` cannot change later output. Writer failures, including short writes,
are returned and may leave partial redacted output; internal dumper failures are
translated to a fixed safe error. There is no unredacted rendering option and
ConfigKit performs no implicit logging.
`Result.Config` is the explicit trusted boundary and contains ordinary typed values,
including secrets. Metadata records, diagnostics, and load errors contain no
configuration values. Explanation snapshots deliberately expose non-secret
configuration history after applying the redaction contract below. Applications
must still keep source labels, resolution reasons, and diagnostic messages value-free
because ConfigKit cannot infer and sanitize secrets embedded in arbitrary application
prose.
### Historical-value explanation
`Result.Explain(path)` returns a detached `Explanation` for any known effective or
historical path. Its effective value and each chronological before/after value are
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
parser values. `Present=false` with empty YAML means absence or deletion; explicit
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.
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
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
explainable; unknown and malformed paths return false.
Every snapshot applies the same historical tag, predicate, and recorded-dependency
sensitivity closure as effective rendering before YAML is exposed. A redacted scalar
is the quoted marker `'[REDACTED]'`, and a container snapshot has `Redacted=true` if
it contains a masked descendant. Typed `Secret` snapshots contain only the configured
environment name. `SecretReference` reports that name only for a successfully
resolved effective reference, and is omitted when its path is classified. Credential
values are never retained in explanation history.
`Result.WriteExplanation(output, path)` renders the safe structured result with the
canonical JSON-pointer path, canonical-versus-input representation labels, effective
source, composite locations, chronological values, resolution reasons and
dependencies, and successful secret-reference lookup attribution. Control characters
in paths, source identifiers and labels, reasons, dependencies, and reference names
are escaped. An unknown path returns a fixed safe error before writing anything;
writer errors and short writes are returned and may leave partial output. There is no
unredacted mode.
Retained history consists of immutable source applications and audited subtree
deltas, rather than a full tree copy per field. Shared nodes count once toward a
4,000,000-node retained-history limit; exceeding it fails loading with
`explain.limit_exceeded`. Replaying one requested path is performed on demand, and
the returned YAML strings and nested records cannot mutate later explanations.
Application commands should render the separate `SecretsReport` immediately after
an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
`WriteEffective`, or `WriteExplanation` on the later load result as appropriate.
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
bundle these independent reports into a command framework.
## Typed secret references
Credential-bearing fields use the opaque `Secret` type and store an environment
variable name in YAML, such as `api_key: OPENROUTER_API_KEY`. `SecretEnv(name)`
constructs the same unresolved reference for application defaults or recorded
resolution. Names must match `[A-Za-z_][A-Za-z0-9_]*`; they are public
configuration and remain visible through `EnvName`, canonical projection, history,
and effective output unless the path is separately classified for redaction.
A non-pointer `Secret` is required. An absent, null, or invalid reference, or an
unset or empty environment value, fails with a safe `secret.*` diagnostic at the
configuration path. `*Secret` is optional only when nil; every non-nil reference
must resolve to a nonempty value. Secret references work in typed structs, maps,
pointers, slices, and arrays. A dynamic `any` value does not establish secret
semantics and cannot contain `Secret`; applications must use a typed slot.
ConfigKit resolves only the effective names after recorded application resolution
and before semantic validation. Each unique name is read with `os.LookupEnv` once
per load. Validation can inspect the credential through the deliberately named
`Value()` accessor and distinguish success with `IsResolved()`. Later loads read
the environment again, and cloned defaults are rebuilt from names so a previously
resolved result cannot retain a stale credential. `Load` never writes to the
environment.
Credential bytes are held only in the per-load lookup state and the trusted returned
configuration, and are returned only by `Value`. Secret formatting uses a safe
public-name representation, JSON marshals only the name, and projection, metadata,
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
validation messages value-free and must not include `Value()` in diagnostics.
For example, a typed pipeline field is declared as:
```go
type PipelineConfig struct {
APIKey configkit.Secret `yaml:"api_key"`
}
```
The executable `ExampleSecretEnv` demonstrates loading and inspecting the safe
reference state without printing its value.
## Explicit secrets-directory bootstrap
`LoadSecretsDir(directory)` is a separate, opt-in startup operation. It is never
called by `Load`, is not a YAML option, and does not participate in configuration
validation. Applications choose the native directory path and should invoke the
operation once before starting concurrent work. The selected path may itself be a
symlink; ConfigKit resolves it to an existing canonical directory before examining
its immediate entries.
The bootstrap enumerates at most 1,024 entries and sorts them by filename. Every
entry must have an ASCII environment-variable name and be a regular, nonsymlink
file. Metadata for all entries is validated before file content is read or process
state is changed. Existing environment variables win, including variables whose
value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
rejects NUL bytes.
All eligible files are read successfully before application begins. Presence is
checked again immediately before each `os.Setenv`: a variable populated since
preflight is skipped. A write failure stops application without rollback, so prior
writes remain and later eligible entries are `not_applied`. This operation is not
atomic and does not provide compare-and-set semantics for concurrent environment
mutation.
`SecretsReport.Entries` returns detached, filename-ordered `SecretFileResult`
records with `loaded`, `skipped_existing`, `not_applied`, or `failed` status.
`ExistingEmpty` distinguishes an empty existing winner. `SecretsReport.WriteTo`
renders only quoted names, canonical paths, statuses, and that empty-state flag;
it never retains or renders file contents. Failures return the non-nil partial
report with a `*SecretsError` containing detached `secrets.*` diagnostics and no
raw filesystem or environment error chain. Applications should retain the report
alongside the later configuration result and explicitly present blocked and failed
files when appropriate; ConfigKit stores no implicit global report.
### Recorded application resolution
An optional `Definition.Resolve` callback runs after successful typed decoding. It
receives the working configuration and a per-load `Resolution` context. The context's
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
null are never confused with absence or static defaults.
`Default` assigns and records a derived value only when its path is not explicit.
`Normalize` always assigns and records. For transformations performed directly by
application code, `Resolution.Record` declares a derived-default or normalization
operation; a recorded container covers changes to its descendants. Records require
a schema-valid path, valid kind, safe nonempty reason, and schema-valid dependency
paths. Repeated records at one path are coalesced: reasons and dependencies retain
their first occurrence, the last helper value is authoritative, and any normalization
makes the combined operation a normalization.
ConfigKit projects the configuration before and after the callback. Every net change
must have an exact or ancestor record. Helper-assigned values must agree with the
final value at their declared path, which supports ordinary map-entry copy/writeback
while detecting forgotten writeback or unrelated targets. Invalid paths, kinds,
values, records, helper targets, and unrecorded mutations fail the load. Equal-valued
derived assignments may still record application intent even when there is no
structural change.
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
locations. Reported errors fail the load; warnings survive on a successful result. A
returned resolver error adds one generic `resolve.hook_failed` error only when the
callback did not report an error, and its text is never exposed. Callback panics are
programming failures. Resolution and validation contexts are callback-scoped; using
a nil, zero, or retained context outside its active callback panics with fixed,
value-free text.
Successful records update effective origins and append detached metadata history
without changing input explicitness. Removed paths leave effective metadata but
retain history. ConfigKit privately snapshots each recorded before/after subtree for
later safe explanation; caller mutations of `Result.Config` cannot alter those
snapshots.
### Semantic validation
An optional `Definition.Validate` callback runs after successful resolution and
receives the resolved configuration plus a per-load `Validation` context. It can
collect multiple independent `validate.error` and `validate.warning` reports.
Warnings continue the lifecycle and are returned on success; any error returns a nil
result with the full safe diagnostic and source report.
Primary and related paths must be valid for the application schema. Omitted declared
fields and missing string-map or dynamic keys are valid report targets; unknown
struct fields and malformed paths produce `validate.invalid_path`. ConfigKit attaches
the exact surviving input location when one exists, otherwise the nearest input
container location, or no primary location for defaults-only synthetic state.
Validation is read-only. ConfigKit projects the configuration immediately before and
after the callback and returns `validate.mutation` at every detected net-change path,
including nested mutable state. A value that can no longer be projected is also a
mutation failure. No mutated or otherwise invalid configuration is published.
Callback panics remain programming failures, and retained contexts use the same
fixed closed-context panic as version and resolution callbacks.
Only schema-bound exported state participates in loading, resolution audit,
validation audit, metadata, and snapshots. Ignored or unexported ordinary fields are
zero in returned detached values. Applications retain responsibility for domain
rules and runtime initialization; validators should not start clients or depend on
live services as a substitute for configuration validation.
## Strict document composition
ConfigKit strictly composes parsed base documents when `Options.Imports` is true.
Application defaults do not participate in this operation.
| Existing node | Incoming node | Result |
| --- | --- | --- |
| Absent | Absent | Absent |
| Absent | Present | Retain the incoming node |
| Present | Absent | Retain the existing node |
| Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
sequences are present atomic values, so they also conflict when repeated; sequences
are never joined by index. Disjoint entries under shared mappings compose, making
stable keyed mappings the natural representation for independently contributed
entries.
An empty mapping contributes no child definitions. Two empty mappings compose, and
an empty mapping neither clears nor conflicts with another mapping's children.
Shared mapping containers retain all contributing value locations as their composite
base origin. A conflict reports the first claim as its primary location and every
remaining claim as related locations, in deterministic source and declaration
traversal order. Traversal order never authorizes replacement. Composition clones
the resulting tree and its ordered claim records rather than mutating or aliasing an
input document.
## Selected profiles
`Options.Profile` selects at most one overlay by name. Names must match
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
`default` select no profile, and `profiles/default.yml` is never loaded implicitly.
Every other name resolves only to `<configuration-root>/profiles/<name>.yml`, with
no extension fallback, case normalization, inheritance, or profile stack. A selected
missing profile is an error.
The profile is a separately policy-validated YAML mapping. It is loaded after the
base import graph is composed and its schema version is checked, but before defaults
and typed decoding. Profile bytes, nodes, and source records count toward the same
aggregate limits as base inputs. Its canonical path must remain within the configured
root and must not duplicate a file already present in the base graph.
Overlay is intentionally distinct from strict composition:
| Existing lower node | Incoming profile node | Result |
| --- | --- | --- |
| Absent | Value | Retain the profile value |
| Value | Absent | Retain the lower value |
| Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
Scalars, sequences, and null are atomic. Thus a profile sequence replaces rather
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
cross-kind replacement is allowed when the final application schema accepts it.
Replacing a subtree removes all displaced descendants from effective presence and
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
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.
## Explicit CLI overrides
`Options.Overrides` accepts only records the application knows were explicitly set
by its CLI parser. Each `Override` supplies a non-root mapping `Path`, a detached
`Value`, and a required safe display `Flag` such as `--workers`; ConfigKit has no CLI
parser dependency. Parser defaults must not be submitted as overrides, and `Flag`
must never contain the raw argument or configured value. General environment
variable overlays are an explicit non-goal: environment access is reserved for
typed secret references.
Overrides apply after the selected profile and before typed defaults/decoding, so
the input precedence is application defaults < composed base < selected profile <
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
effective override tree. Overrides also work with `NoSource`.
Values are converted directly into ConfigKit's owned tree without YAML parsing or
string coercion. Supported values are `nil`, built-in strings and booleans, all
built-in signed and unsigned integer widths except `uintptr`, finite `float32` and
`float64`, and recursive `[]any`/`map[string]any`. Strings stay strings; duration and
secret fields therefore receive string values. Nil is explicit null; empty strings,
false, and zero remain intentional; empty sequences replace whole lists; and mapping
values recursively overlay existing mappings. Map keys are sorted canonically and
sequence order is preserved.
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
configured schema-version path. Unknown fields and effective type mismatches remain
ordinary strict-decoder errors.
At most 1,024 records are accepted, and each record creates one synthetic CLI
source after file/profile sources in supplied order. Override sources share the
1,024-source ceiling. A record has an 8 MiB string-byte limit; override strings and
source bytes share the 64 MiB aggregate limit. Synthetic mapping keys and values
count toward the existing 1,000,000-node and depth-256 ceilings. Cycles, named or
unsupported types, nonfinite floats, and limit excesses fail with safe `override.*`
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
arguments or values.
Override origins are explicit CLI origins. History retains replaced base/profile
definitions and records equal-value replacements as well as structural changes.
Caller-owned maps and slices are snapshotted during loading and must not be mutated
concurrently with the call. The executable `ExampleOverride` shows translating a
parser's explicit-set state into records while preserving an explicitly supplied
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
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
imports disabled, any occurrence remains `import.disabled`.
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
body before its imports. Order stabilizes source records and diagnostics but never
permits replacement. Required missing paths fail; `optional: true` suppresses only
target nonexistence, not invalid paths, containment failures, directories,
permissions, parse errors, or composition errors.
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
Backslashes escape pattern characters rather than acting as directory separators.
ConfigKit validates the complete pattern before filesystem traversal, walks only
directories selected by preceding segments, and reads wildcard directories in
bounded batches. Every examined entry consumes the separate visit budget, including
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
each declaration before depth-first source traversal. A required zero-match glob is
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
walk failures, matched directories, invalid documents, escapes, and repeated targets
remain errors. Final candidates are bounded by the remaining source limit before
their contents are accumulated.
The configuration root defaults to a selected file's canonical directory. A file
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,
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
distinct paths remain distinct sources.
An active-stack repeat is `import.cycle` and reports the complete cycle's import
sites. A previously visited canonical path is `import.repeated` and reports both
inclusion routes, including diamonds and aliases. Each accepted file is read once.
Imported fragments may contain their own root directives, but may not claim the
application's schema-version path or replace one of its ancestors; the version must
come from the selected source. No directory is scanned implicitly, including one
named `conf.d`, and ConfigKit performs no remote fetching.
## YAML documents
The private YAML boundary currently accepts exactly one nonempty document whose root
is a mapping. Comments and block or flow collections are supported. It rejects:
- empty input or an empty document;
- every additional document, including an empty one;
- duplicate decoded mapping keys;
- non-string mapping keys;
- explicit tags, anchors, aliases, and merge keys;
- a quoted or plain mapping key equal to `<<`; and
- input exceeding the fixed depth or node limits.
Mapping key comparison is exact: it performs no case folding or Unicode
normalization. The private document tree retains declaration order, exact validated
number spelling, source identifiers, and separate key/value line and column
locations. It contains no upstream parser nodes or types.
Root depth is zero. Input depth is limited to 256 and each parsed input tree to
1,000,000 nodes, counting mapping keys as input nodes. The upstream parser also has
a private calibrated depth guard; passing that guard never bypasses ConfigKit's tree
limits.
## Scalars
Scalar meaning is determined by ConfigKit rather than upstream coercion:
| Input form | Meaning |
| --- | --- |
| Quoted, literal, or folded scalar | String |
| Plain `true` or `false` | Boolean |
| Plain `null`, `~`, or an empty mapping value | Null |
| JSON decimal number | Number, retaining its exact spelling |
| Date/time-looking or other ordinary plain text | String |
JSON numbers permit an optional minus, a zero or nonzero decimal integer without a
leading zero, an optional fraction with at least one digit, and an optional decimal
exponent. Hexadecimal, octal, digit separators, leading plus, leading/trailing-dot
forms, incomplete exponents, and non-finite values are rejected. Legacy boolean
words (`yes`, `no`, `on`, and `off`) and non-lowercase variants of the reserved
boolean/null words are rejected unless quoted.
Mapping keys use the same scalar classification and must classify as strings. Quote
keys that otherwise classify as a boolean, number, or null.
## Application schema binding
ConfigKit builds a private reflection descriptor graph for each schema operation;
it keeps no global mutable descriptor cache. A configuration root must be a struct
or string-keyed map. Nested supported shapes are:
- booleans, strings, signed and unsigned integers, and finite floating-point types;
- `time.Duration`, typed `Secret` references, and paired
`encoding.TextMarshaler`/`TextUnmarshaler` scalars;
- pointers, structs, string-keyed maps, slices, fixed arrays, and `any`; and
- recursive type definitions whose eventual application values are acyclic.
Named scalar types retain their Go identity. `uintptr`, complex numbers, functions,
channels, unsafe pointers, non-string map keys, and nonempty interfaces are not
configuration types. A custom text scalar must provide both directions on its value
or pointer method set. Building a descriptor only inspects method sets; it never
executes application methods or parser-specific hooks.
Exported struct fields use an explicit `yaml` name or the lowercase Go field name.
Unexported fields and `yaml:"-"` are ignored. `omitempty` is accepted but does not
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
named fields unless explicitly inlined. `configkit:"secret"` is the only current
ConfigKit field tag and marks its bound field or container for secret classification
and redacted inspection.
Schema path traversal uses these same bindings. Explicit key segments address
mappings, explicit index segments address sequences, and parsed numeric pointer
tokens become indices only in a sequence. Sequence indices cannot have leading
zeroes; fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
remain schema-valid for application-defined descendants, but dynamic values cannot
introduce typed secret references.
Descriptors are reused by typed decoding, projection, and cloning so those operations
cannot acquire different field bindings.
### Canonical module options
Applications with a known module catalog represent each configured item as an object
with an explicit module identifier and an options mapping. Ordinary typed structs,
including a small union of optional option fields, use the same strict binding above;
application validation decides which fields and combinations apply to each module.
String shorthand, a library module registry, and custom structural decoder hooks are
not supported alternatives.
When a runtime-selected catalog genuinely requires dynamic options,
`map[string]any` retains ConfigKit's canonical dynamic scalars. In particular,
integers are `int64`; application validation owns allowed keys, value constraints,
range checks, and conversion to runtime `int` or catalog objects. ConfigKit does not
provide generic subtree conversion, and consumers must not add a YAML marshal/decode
round trip to exercise supported behavior. The
[consumer adoption guide](consumers/README.md) contains compiled typed and dynamic
examples.
## Defaults and typed decoding
The private decoder starts from an independently owned clone of the application's
typed defaults and applies only explicitly present input nodes. Defaults therefore
never become explicit, while equal-valued input remains explicit. Successful decode
produces detached typed state, baseline and final canonical trees, an input-presence
index, and default/input origins for the later public loading lifecycle.
| Configured input | Effect on the typed baseline |
| --- | --- |
| Absent field | Retain the default |
| Explicit false, zero, or empty string | Replace the default |
| Partial mapping or string-keyed map | Recurse and retain omitted default children or entries |
| Empty mapping | Retain existing mapping children |
| Sequence, including an empty sequence | Replace the entire sequence |
| Pointer object | Recurse into a non-nil default; otherwise allocate an object whose omitted children have ordinary Go zero values |
| Allowed null | Replace the value with nil and suppress its default |
Null is allowed only for pointers, maps, slices, and dynamic `any` values. It is an
error for scalars, structs, and arrays. New map entries and sequence elements begin
from their Go zero values; ConfigKit does not invent entry-level defaults.
Decoding is strict at every typed depth. Unknown struct fields, type mismatches,
invalid nulls, numeric overflow, and arrays with the wrong length are errors at the
original semantic path and source coordinates. String-keyed maps accept arbitrary
keys but preserve the strict schema of their values; inline maps accept unmatched
keys according to their element descriptor. Multiple independent field failures are
collected without publishing the partially decoded object.
Booleans and strings are not coerced. Integer destinations require integer syntax
and a value in the destination type's range. Float destinations accept finite JSON
numbers representable by their destination precision. Dynamic numbers become
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
range and finiteness checks. Named scalar types retain their identity. Durations
decode only from strings accepted by `time.ParseDuration`; integers are rejected.
Paired text scalars decode only from strings through `encoding.TextUnmarshaler`, and
codec failures are classified without exposing application error text or input
values.
This decoder is available only through `Load`; ConfigKit exposes no generic document
conversion API.
## Typed projection and ownership
ConfigKit can internally project a schema-valid application value directly into its
owned canonical tree without YAML serialization. Projection includes every bound
field, including ordinary zero values; `omitempty` has no effect. Struct fields keep
binding order, dynamic map keys sort by decoded string, and sequences keep their
order. Integers retain exact base-10 values, finite floats use their destination
precision, durations use `time.Duration.String`, and paired text scalars use their
canonical marshaled text.
Nil pointers, maps, slices, and dynamic values project as null. Non-nil empty maps
and slices remain empty containers, preserving the distinction. Recursive acyclic
objects are supported. Cyclic values, non-finite floats, unsupported dynamic concrete
types, invalid codec output, and depth/node-limit violations fail with safe schema
classifications that omit codec error text.
The same descriptor graph creates detached typed copies. Pointers, maps, slices,
arrays, nested dynamic values, and shared references are recursively copied; shared
pointer identity is not preserved as configuration meaning. Paired text scalars are
rebuilt by unmarshaling their canonical text into fresh values, detaching private
backing storage. Ignored and unexported struct fields are zeroed rather than copied.
Private structural comparison operates on canonical trees. Mapping additions,
removals, and descendant replacements retain semantic paths. A sequence is atomic:
any sequence change reports the sequence path and never invents correspondence
between old and new indices. Cross-kind and null/container changes replace the
affected path.
These mechanisms support decoding, resolution auditing, metadata,
and rendering. They are not a public generic conversion or cloning API.
## Safe failures
Source failures use stable `source.*` codes and YAML failures use stable `yaml.*`
codes declared by the root package. Diagnostics contain safe classifications,
semantic paths when applicable, and available source coordinates. Raw parser,
conversion, reader-error, and input text is not forwarded. Source labels and
diagnostic messages are application-visible and therefore must never contain
secrets; terminal control characters are escaped by `WriteDiagnostics`.

98
docs/consumers/README.md Normal file
View File

@@ -0,0 +1,98 @@
# Consumer Adoption Guide
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
profile traversal, precedence, provenance, secret lookup, and safe inspection from
applications. Consumers still own their configuration structs, defaults, semantic
validation, schema-version policy, CLI parsing, runtime matching and conversion,
resource checks, remote acquisition, and service initialization.
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
four sanitized shapes using only ConfigKit and the Go standard library:
| Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- |
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
| Notarius-like | Canonical object bindings, a string schema version, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
credentials, network calls, or dependency on a sibling checkout.
## Happy path
An application defines ordinary types and passes one explicit source. Optional
features appear only when the application needs them:
```go
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
Definition: rules,
Imports: useDeclaredImports,
Profile: selectedConfigProfile,
Overrides: explicitlySetFlags,
})
if err != nil {
return err
}
return start(result.Config)
```
Simple consumers use neither imports nor profiles. A complex consumer can opt into
both without redefining their syntax. ConfigKit profiles are configuration overlays;
they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts.
Known module catalogs should use canonical object entries with a module identifier
and typed options. A small union struct is sufficient when the available option
fields are known; validation selects which fields apply to each module. Runtime-
selected catalogs may retain `map[string]any`, whose integers are `int64`; applications
validate keys and ranges and convert only at their runtime boundary. Neither shape
needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree
conversion API.
## Intentional migration changes
The examples preserve domain meaning while adopting one configuration convention:
- Explicit empty strings, lists, zeroes, false values, and nulls remain intentional.
ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation
rejects an explicitly or programmatically supplied empty module/template list as a
domain rule. Loaded and constructed values use the same validator.
- Canonical module and object bindings replace string shorthand. Unknown fields and
incompatible option shapes fail at their semantic paths.
- Schema versions are strings. Consumers with integer versions migrate their files
instead of enabling another scalar convention.
- Integers inside dynamic options are portable `int64` values. Runtime `int`
conversion is explicit and range checked.
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
tags/scalars, trailing documents, and unknown typed fields. Diagnostics expose
stable classifications and safe paths, not copied parser text.
- General environment-variable configuration overlays retire. Application CLI
parsers translate only explicitly set configuration flags into `Override` records.
Separate invocation state remains outside configuration.
- Credential fields migrate to environment-name `Secret` references. Applications
explicitly call `LoadSecretsDir` before `Load` when they choose file bootstrap;
blocked empty variables remain blocked, and ordinary `Load` never changes the
process environment.
These examples are migration evidence, not completed changes to Narratio, Notarius,
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
wiring, runtime catalogs, and operational documentation remains separate downstream
work.
## Maintained API examples
Every nontrivial initial API sketch has a compiled example or a
focused behavioral owner:
| Roadmap surface | Maintained owner |
| --- | --- |
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
| Source/import/profile pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
The package-level import-boundary test covers the consumer examples too: supported
usage names no YAML dependency, parser type, parser option, or parser error chain.

View File

@@ -1,8 +1,13 @@
# Development
This is the contributor entry point for ConfigKit. Read the task-specific owners
below before making changes. The repository is in pre-implementation: it has no
Go module, packages, tests, or executable examples yet. The
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.
## Initial orientation
@@ -18,10 +23,10 @@ Go module, packages, tests, or executable examples yet. 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 design](roadmap/initial.md), and relevant ADRs listed below |
| 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 |
| Examples or copyable assets | The owning implemented contract and related maintained examples, once available |
| Release preparation | [Release policy](policy/release.md) and [release-readiness gaps](roadmap/release-readiness.md); the operational runbook will be added before release |
| 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) |
Accepted decisions:
@@ -34,27 +39,47 @@ Accepted decisions:
For cross-cutting changes, follow every applicable row. The
[implementation plan](roadmap/implementation.md) owns the ordered stages and their
acceptance checks; all remain pending until implementation evidence is recorded.
acceptance checks and recorded implementation evidence.
## Baseline validation
For the current documentation-only repository:
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
dependencies explicitly before entering an offline development or validation
environment:
```sh
go mod download
```
Run the library baseline from the repository root:
```sh
go test ./...
go vet ./...
go build ./...
git diff --check
```
Also inspect new untracked documents, verify changed local links and heading
fragments, and review paths, examples, ownership, and current-versus-planned
claims. The command above does not check links or untracked content.
Go validation is not runnable until a module and packages exist. The scaffolding
change must establish and verify library test, vet, and build commands here,
including their prerequisites. 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 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.
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.
## Fuzz validation
Run the bounded fuzz targets independently from the repository root. These exact
commands keep each campaign short and constrain concurrent workers:
```sh
go test . -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2
go test . -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
```

413
docs/internal/extraction.md Normal file
View File

@@ -0,0 +1,413 @@
# Source Extraction Inventory
This inventory records reusable implementation evidence from sibling applications.
Those checkouts are review inputs only: ConfigKit does not import them, and its
build and tests must not require them. ConfigKit's current contracts and roadmap
govern every adaptation.
## Inspected revisions
| Application | Revision | Worktree | Role |
| --- | --- | --- | --- |
| Narratio | `b68435128be1e24b94fc7ec259d6e9093669fd99` | Clean `main` tracking `origin/main` | Primary parsing, composition, import, profile, presence, defaults, resolution, and inspection source |
| WeatherReporter | `5d306a202471e10fe98923a1df27d0b0fce5c666` | Clean `main` tracking `origin/main` | Secrets bootstrap and simple-consumer/defaults/options evidence |
| Notarius | `77ae132e5609916da40dcd4fcc749c000a63cbac` | Clean `main` tracking `origin/main` | Canonical binding, string-version, and dynamic-option migration evidence |
| Seriatim | `0fc92f364316c874d366b00cdc884a33e1900b25` | Clean `main` tracking `origin/main` | Independent ordered speaker/autocorrect rule evidence |
These revisions were inspected on 2026-09-06. Paths below are relative to each
application repository. Record later source revisions and adaptations here when
the owning implementation work extracts a symbol or case.
## Adaptation rules
- Copy and generalize only the smallest applicable mechanism or behavioral case.
- Replace upstream YAML nodes, application types, commands, logging, digests, and
provider registries with ConfigKit-owned boundaries.
- Preserve strictness, deterministic behavior, and useful failure coverage while
changing expectations that conflict with ConfigKit's accepted contracts.
- Use sanitized local fixtures only. Audio, transcripts, credentials, private
configuration, and whole application package trees are application-only.
- Record the exact copied/generalized symbols and tests in the owning section when
extraction occurs. Assignment prose records the original work boundary; dated
implementation records state what was actually generalized.
## Narratio evidence
### Source parsing and strict input
Assigned to the YAML/source implementation:
- Production: `internal/config/load.go` functions `decodeStrictYAML`,
`decodeStrictYAMLFromReader`, and `LoadSessionBytesWithOptions`; and
`internal/config/composition.go` functions `parseCompositionDocument`,
`parseCompositionBytes`, and `buildCompositionNode`.
- Cases: `TestLoadersRejectTrailingYAMLDocuments`,
`TestStrictYAMLRejectsParserExposedEmptyTrailingDocument`, and
`TestStrictYAMLAcceptsSingleDocumentAndRejectsUnknownFields` in
`internal/config/loader_strict_test.go`; plus duplicate-key, alias, non-mapping,
malformed-input, scalar spelling, presence, declaration-order, and source-label
coverage in `TestParseCompositionDocumentRetainsPresenceOwnershipAndDeclarationOrder`
and `TestParseCompositionDocumentRejectsAmbiguousOrMalformedYAML`.
- Preserve: labeled reader/byte input, strict unknown-field intent, rejection of
trailing documents (including an empty second document), duplicate detection,
and source-aware failures.
- Adapt: translate v4 parser nodes into ConfigKit's private document tree, apply
ConfigKit's scalar grammar and limits, and replace raw parser errors with safe
diagnostics. Narratio's application decoders and YAML node types are not copied.
Implementation record (2026-09-06): the labeled byte/reader ownership and strict
single-document cases above were generalized into `source.go`, `parse.go`, and
`internal/yaml`. No production line was copied verbatim. ConfigKit adds byte and
tree ceilings, shared atomic reader claims, regular-file checks, exact numeric
lexemes, parser-depth calibration, ConfigKit-owned locations, safe diagnostic
codes, and an AST import-boundary regression. Application typed decoding, parser
errors, YAML node types, and session-specific loaders were not retained.
### Strict composition
Assigned to document composition:
- Production: `mergeAdditiveComposition`, `mergeAdditiveCompositions`,
`validateAdditiveClaims`, `appendCompositionClaims`, `mergeAdditiveNodes`,
`newCompositionConflict`, and cloning/path/source helpers in
`internal/config/composition.go`.
- Cases: `TestMergeAdditiveCompositionJoinsOnlyDisjointMappings`,
`TestMergeAdditiveCompositionRejectsEveryDuplicateClass`, and
`TestMergeAdditiveCompositionReportsAllClaimingSources`.
- Preserve: recursive joining of disjoint mappings, collisions for every overlapping
non-mapping value (including equal values and sequences), all claimant sources,
deterministic traversal, and nonmutation of inputs.
- Adapt: empty mappings follow ConfigKit's no-child operation rule; paths use the
ConfigKit semantic path model; both precise locations and composite origins are
retained. Default values never participate in strict composition.
Implementation record (2026-09-06): `composeDocuments`, its pre-order claim
collection, conflict construction, and recursive mapping join in `compose.go` adapt
the assigned production symbols. `TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims`,
`TestComposeDocumentsRejectsEveryPresentNonMappingOverlap`, and
`TestComposeDocumentsReportsAllClaimantsAtExactLocations` adapt the assigned cases.
The intentional empty-mapping difference is covered separately: empty mappings retain
container provenance but contribute no child claims. No other semantic deviation
from the extracted additive composer was required.
### Imports
Assigned to explicit import traversal and glob handling:
- Production: `loadPipelineCompositionSources`,
`splitPipelineCompositionEnvelope`, `loadPipelineImports`, and related ownership
structures in `internal/config/pipeline_composition.go`.
- Cases: `TestSplitPipelineCompositionEnvelopePreservesInputAndDeclarationOrder`,
`TestSplitPipelineCompositionEnvelopeRejectsMalformedShapesWithoutMutation`,
`TestLoadPipelineCompositionImportsDisjointFieldsAndTracksOwnership`,
`TestLoadPipelineCompositionMergesDisjointKeyedEntries`,
`TestLoadPipelineCompositionRejectsBaseConflictsWithAllSources`,
`TestLoadPipelineCompositionRejectsUnsafeOrInvalidImports`,
`TestLoadPipelineCompositionRejectsSameFileAliasesAndImportedComposition`,
`TestLoadPipelineCompositionReportsImportedParseAndSchemaSources`, and
`TestLoadPipelineCompositionKeepsRelativePathsRootBased`.
- Preserve: explicit declarations, declaration-order traversal, disjoint
composition, conflict attribution, malformed/unreadable target failures, directive
removal, and imported parse/schema source labels.
- Adapt: replace the root-only `composition` envelope with recursive
`_configkit.imports`; add path and glob declarations, bounded depth-first graph
traversal, canonical containment, cycle/diamond routes, and fixed source IDs.
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,
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
recursive bounded DFS, `os.OpenRoot` confinement, canonical symlink identity,
cycle/diamond route locations, optional-path semantics, aggregate budgets, and
fragment version-placement enforcement. That checkpoint parsed glob syntax behind an
explicit unsupported gate that the subsequent glob implementation removed; hard
links intentionally replace Narratio's same-file rejection with path identity.
Glob-import implementation record (2026-09-06): ConfigKit retains the extracted
declaration order, import failures, composition, and identity cases while adding the
new `expandImportGlob`, `readGlobMatches`, and pattern-validation mechanisms in
`glob.go`. Focused cases cover lexical ordering, multiple declarations, overlap,
directories, hard links, contained and escaping symlinks, unmatched declarations,
malformed and recursive patterns, escaped metacharacters, character classes,
permission failures, explicit `conf.d` use, and entry/candidate budgets. This work
has no Narratio production equivalent; it uses the selected standard-library
`path.Match` and bounded `File.ReadDir` adaptation rather than its flat loader.
### Profiles and overlay
Assigned to selected-profile overlay:
- Production: `mergeOverlayComposition`, `mergeOverlayNodes`,
`selectPipelineProfile`, `loadPipelineProfileOverlays`, and
`loadedProfileOverlay`.
- Cases: `TestLoadPipelineProfilesSelectDefaultAndExplicitOverlay`,
`TestLoadPipelineProfileSelectionErrors`,
`TestPipelineProfileNamesRejectControlCharacters`,
`TestLoadPipelineProfilesValidateEveryDeclaredOverlay`,
`TestLoadPipelineProfileOverlaySemantics`,
`TestLoadPipelineProfileRejectsNullAndKindChanges`, and
`TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines`.
- Preserve: recursive mapping overlay, atomic scalar/sequence replacement,
deterministic selection failures, source ownership, and independent results.
- Adapt: load only the selected fixed profile path; lowercase `default` means no
overlay. ConfigKit permits null and cross-kind replacement and does not support
declared defaults, inheritance, arbitrary profile paths, or validating every
unselected overlay.
Implementation record (2026-09-06): `overlayDocuments`, `mergeOverlayMappings`,
`loadProfileDocument`, and `profileDirectiveDiagnostics` adapt the assigned merge,
selection, loading, and ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
`TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults`, profile selection/error,
directive/version, containment, and budget cases adapt the assigned tests. ConfigKit
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
retains displaced descendant history, rejects any profile directive or version-path
contribution, and counts the profile in aggregate budgets. Narratio's declared
default, multi-overlay validation, inheritance-like application conventions, and
null/kind restrictions were not retained.
### Effective inspection and provenance
Assigned to layered metadata, redaction, rendering, and explanation:
- Production: `EffectivePipelineValues`, `EffectivePipelineSources`,
`MarshalEffectivePipeline`, `effectivePipelineFieldPaths`,
`effectivePipelineOwners`, and composition `semanticRecords`/
`compactSemanticRecords`.
- Cases: `TestCompositionCanonicalOutputsAreDeterministic`,
`TestEffectivePipelineValuesIgnoreEquivalentSourceLayout`,
`TestConfigValidateAndShowPipelineOnlyAreSideEffectFree`,
`TestConfigSourcesReportsStableOwnership`,
`TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins`,
`TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion`, and
`TestConfigSourcesReportsRootImportProfileAndDefaultOwnership`.
- Preserve: deterministic effective projection, stable logical paths, source
ownership, side-effect-free inspection, and domain-attribution evidence.
- Adapt: project directly from ConfigKit-owned typed values without YAML round trips;
add line/column locations, composite origins, complete operation history, detached
records, and redaction. Narratio commands, diff/digest APIs, generated artifact
attribution, campaign logic, and party expansion remain application-owned.
Layered-provenance implementation record (2026-09-06): ConfigKit integrated its
previously extracted source claims, overlay displacement records, typed baseline,
and audited resolution snapshots in `load_metadata.go`. The representative
`TestLayeredProvenanceAgreesWithResolvedSnapshot` adapts Narratio's stable ownership,
profile ownership, deterministic effective projection, and side-effect-free
inspection cases across one default/import/profile/resolution workflow. ConfigKit
adds chronological descendant history for parent and whole-sequence operations,
composite empty-mapping origins, canonical resolution reasons/dependencies, private
snapshot agreement, defensive nested copies, and concurrent load isolation. Value
That checkpoint deliberately left rendering, redaction, explanation, commands,
generated attribution, diffs, and digests outside its scope.
Effective-rendering implementation record (2026-09-06): ConfigKit adapts Narratio's
deterministic effective-output and side-effect-free inspection cases in
`classifySecretPaths`, `redactedDocument`, `Result.WriteEffective`, and the private
`internal/yaml.Write` adapter. Tests cover stable sorted mappings, preserved sequence
order, explicit indentation/line/document formatting, tagged containers, dynamic
current and historical paths, fixed-point dependency cycles, nil/empty distinctions,
resolved values, text-scalar snapshot reuse, application YAML-hook isolation,
non-disclosure through failures and record formatting, caller mutation, and writer
failures. Unlike Narratio's marshal/reparse path, ConfigKit renders only its owned
frozen tree and substitutes redaction before the upstream dumper boundary. Historical
value explanation and secret references were implemented at their later owning
boundaries and are exercised by the consumer startup example below.
### Defaults, presence, and domain resolution
Assigned across schema, decoding, resolution, validation, and consumer examples:
- Production: `NormalizeConfig.UnmarshalYAML`, `outputPathWasSet`,
`applyPipelineDefaults` and its component default helpers, `Resolve`,
`ResolveLoadedPipelineCampaign`, `mergeCampaignSession`, `selectStableInput`, and
application-supplied byte loading in `internal/app/config_loader.go`.
- Cases: `TestNormalizeLoadAndValidate`,
`TestRepresentativeLoadDefaultsAndValidation`,
`TestMarshalEffectivePipelineRendersStablePublicConfiguration`,
`TestPipelineCampaignRegistryStrictDecode`,
`TestCampaignSessionMergeFillsStableInputs`,
`TestCampaignSessionMergeSessionOverridesStableInputs`,
`TestLoadWithSessionOptionsCarriesExplicitProfilePresence`, and the maintained
extraction-example coverage in `TestMaintainedExtractionExamplesPreservePublishedContracts`.
- Preserve: fresh defaults, explicit-presence-sensitive domain decisions, strict
nested types, stable input selection, application-supplied labeled bytes, and
validation after resolution.
- Adapt: presence comes from metadata rather than custom YAML hooks or pointer/zero
guesses; explicit empty and null values are retained; defaults are cloned and
application-owned; every domain mutation is recorded. Campaign/session fallback,
search paths, fetching, storage, and concrete default values remain in Narratio.
Implementation record (2026-09-06): ConfigKit generalized Narratio's fresh-default,
strict nested-type, and explicit-presence cases into `decode.go` and
`decode_test.go`. The decoder applies the owned document tree over a detached typed
baseline, treats mappings recursively and sequences atomically, preserves explicit
false/zero/empty/null, rejects unknown nested fields, and returns private presence
and default/input origin artifacts. Unlike Narratio's custom YAML hooks and
pointer/zero inference, presence comes directly from the parsed tree and equal-valued
input remains explicit. ConfigKit also adds destination-width numeric checks,
dynamic `int64`/`float64`, exact arrays, duration strings, paired text codecs, safe
multi-error collection, and repeated-result isolation. Resolution, validation,
consumer defaults, and all campaign/session algorithms remain assigned to later
work.
Lifecycle integration record (2026-09-06): the public single-source `Load` boundary
combines the already extracted labeled-source and strict-decoding mechanisms with
Narratio's fresh-default and presence expectations. ConfigKit adds an application-
defined pre-decode string-version check, defaults-only `NoSource` operation,
detached base/default metadata and source reports, and explicit gates for every
later lifecycle option. No application loader or production line was copied.
Narratio's resolution, validation, import/profile, campaign/session, and command
behavior remains assigned to its later owning work.
Resolution implementation record (2026-09-06): ConfigKit adapted the intent of
Narratio's `Resolve`, `applyPipelineDefaults`, component default helpers,
`mergeCampaignSession`, and `selectStableInput` into a generic recorded resolver in
`resolution.go`. The synthetic timeout/artifact cases preserve explicit-input
decisions, derived dependencies, normalization, and map copy/writeback without
copying campaign/session algorithms or concrete defaults. ConfigKit adds structural
mutation auditing, container coverage, helper/path consistency checks, equal-value
intent, coalesced repeated writes, private before/after snapshots, deterministic
record ordering, and safe hook diagnostics. Semantic validation remains assigned to
the next lifecycle work.
Validation implementation record (2026-09-06): ConfigKit generalized Narratio's
`TestNormalizeLoadAndValidate`, `TestRepresentativeLoadDefaultsAndValidation`,
strict registry decoding, and maintained extraction-example expectations into the
post-resolution `Validation` lifecycle and synthetic consumer examples. ConfigKit
keeps domain rules application-owned, resolves schema-valid missing/dynamic report
paths to nearest input locations, collects independent warnings/errors, and audits
the full typed projection to reject validator mutation. Live resources, registries,
campaign/session logic, commands, and application runtime initialization were not
extracted.
### Secrets bootstrap
Assigned to the explicit bootstrap operation:
- Production: `loadSecretsFromConfigWithDependencies`, `validateSecretDirectory`,
`validateSecretFile`, `readAPIKeyFile`, and
`internal/fileops/read_bounded.go` function `ReadFileBounded`.
- Cases: `TestLoadSecretsFromConfigLoadsValidFiles`,
`TestLoadSecretsFromConfigPreservesExistingEnv`,
`TestLoadSecretsFromConfigRelativeDirUsesCWD`,
`TestLoadSecretsFromConfigMissingDirFails`,
`TestLoadSecretsFromConfigUnreadableValidEntryFails`,
`TestLoadSecretsFromConfigRejectsUnsafeModes`,
`TestLoadSecretsFromConfigRejectsNonRegularAndOversizedEntries`, and
`TestLoadSecretsFromConfigRejectsSymlinkAndAncestorReplacement`.
- Preserve: bounded reads, valid environment names, existing variables (including
empty values) winning, regular-file checks, symlink rejection, and failure cases.
- Adapt: use ConfigKit's deterministic report/preflight/apply state machine and
fixed budgets. Logging, configuration-driven implicit invocation, private-mode
conventions, and application API-key plumbing remain in Narratio.
Implementation record (2026-09-06): `LoadSecretsDir`, `readSecretsEntries`,
`readSecretValue`, and the private environment/file seams in `secrets.go` adapt
Narratio's bounded-read and preserve-existing mechanisms at revision
`b68435128be1e24b94fc7ec259d6e9093669fd99`. The corresponding tests retain its
valid-file, existing-variable (including empty), missing/permission/read,
nonregular, symlink, and byte-limit cases. ConfigKit validates invalid entries
rather than skipping them, permits ordinary deployment permission modes, resolves
the selected directory symlink, performs complete preflight before mutation, and
emits per-file safe records without application configuration or logging.
## WeatherReporter evidence
### Secrets bootstrap
Assigned to the explicit bootstrap operation:
- Production: `loadSecrets`, `applySecrets`, `restoreEnvironment`, `secretValue`,
and `environmentValue` in `internal/config/secrets.go`.
- Cases: `TestLoadSecretsDisabledLeavesEnvironmentUnchanged`,
`TestLoadSecretsOverwritesExistingEnvironment`,
`TestLoadSecretsTrimsOneTrailingLineEnding`,
`TestLoadSecretsRejectsInvalidDirectoryEntries`,
`TestLoadSecretsRejectsMissingDirectory`,
`TestLoadFileSecretDirectoryFailureLeavesEnvironmentUnchanged`,
`TestLoadFileValidationFailureLeavesSecretEnvironmentUnset`, and
`TestApplySecretsRollsBackOnEnvironmentFailure`.
- Preserve: separate read/preflight and apply mechanisms, one-newline handling,
invalid-name/type/read coverage, no writes after preflight failure, and controlled
environment-write failure coverage.
- Adapt: existing variables—including empty ones—are skipped, never overwritten;
application failures produce an accurate partial report and no rollback. Skip
entries are not read. Bootstrap is explicitly invoked and remains independent of
YAML loading and semantic validation.
Implementation record (2026-09-06): the same `secrets.go` state machine adapts
WeatherReporter's `loadSecrets`/`applySecrets` separation, environment-name check,
regular-file rejection, and exact one-LF/CRLF removal at revision
`5d306a202471e10fe98923a1df27d0b0fce5c666`. Tests adapt its newline matrix,
invalid-entry and preflight-no-write cases, and controlled application failure.
The overwrite/rollback expectation is intentionally replaced by existing-winner,
presence-recheck, and partial-progress statuses; no configuration loader invokes
the bootstrap.
### Defaults, explicit presence, and module options
Assigned to consumer-shaped examples and migration checks:
- Production: `Load`, `mergeFile`, `normalizeReportModules`,
`traverseReportModules`, `normalizeModuleOptions`, and `decodeKnownOptions`.
- Cases: `TestDefaults`, `TestOutputDirectoryLoading`,
`TestOutputDirectoryValidationIsConsistentForLoadedAndConstructedConfigs`,
`TestLoadMinimalExampleConfig`,
`TestReportDistributorPathTemplatesSetTracksExplicitEmptyList`,
`TestValidateReportModuleKeysWithoutMutatingOptions`,
`TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig`,
`TestReportModuleOverridesCanonicalizesTypedPointerOptions`,
`TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig`,
`TestReportModuleValidationConsistentForLoadedAndConstructedConfig`,
`TestConstructedReportOverridesUseExportedFields`, and
`TestConstructedExplicitEmptyReportOverridesAreRejected`.
- Preserve: defaults-only use, consistent loaded/constructed outcomes, explicit
empty-list domain rejection, typed module-specific options, normalization without
mutating caller input, and unknown-field rejection.
- Adapt: ConfigKit permits empty lists structurally and exposes their presence to
application validation. Object bindings replace shorthand, explicitly set flags
become CLI override records, and general environment overlays retire. Report
semantics, registries, rendering, and module option validation remain application
responsibilities.
Consumer-example implementation record (2026-09-06): at Narratio revision
`b68435128be1e24b94fc7ec259d6e9093669fd99`, the maintained consumer cases adapt
`TestMaintainedExtractionExamplesPreservePublishedContracts`,
`TestConfigValidateAndShowPipelineOnlyAreSideEffectFree`, and the stable source-
ownership tests into a sanitized partial-import/artifact fixture. ConfigKit replaces
application composition envelopes and command output with `_configkit.imports`,
typed defaults, recorded resolution, metadata, and explanation. Artifact meaning,
model/pipeline profiles, commands, and generated-domain attribution remain in the
example application layer; no production function was copied.
At WeatherReporter revision `5d306a202471e10fe98923a1df27d0b0fce5c666`,
`TestDefaults`, explicit-empty-list cases, loaded/constructed consistency cases, and
nonmutating module-option normalization were adapted into the WeatherReporter-shaped
fixtures and tests in [`examples/consumers`](../../examples/consumers). ConfigKit's
detached defaults replace source mutation, canonical module objects replace string
shorthand, ordinary validation retains empty-list and option compatibility rules,
and explicit `Override` records replace external configuration-field overlays. The
startup example composes the separately implemented bootstrap report, typed secret
lookup, and explanation APIs without copying WeatherReporter's registries or
rendering/runtime modules.
Notarius revision `77ae132e5609916da40dcd4fcc749c000a63cbac` supplied
binding/version/dynamic-option migration evidence only. The local example uses an
ordinary canonical object, a string `VersionRule`, ConfigKit's `int64` dynamic
integer convention, and application range checking; it does not copy a catalog or
environment overlay. Seriatim revision
`0fc92f364316c874d366b00cdc884a33e1900b25` supplied ordered rule-file evidence.
The local examples load speaker and autocorrect files independently and keep their
matching/replacement algorithms outside ConfigKit. Neither source is a build or test
dependency.
## Application-only material
Do not extract application commands, filesystem discovery, logging, runtime
initialization, remote clients, LLM/provider setup, audio/transcript fixtures,
configuration digests, report rendering, campaign/session algorithms, or consumer
resource validation into ConfigKit. Maintained consumer examples use only small
synthetic types and fixtures to prove that these responsibilities remain
straightforward.

View File

@@ -1,10 +1,68 @@
# Internal Component Overview
ConfigKit has no implemented Go packages or components yet. This document will
inventory components and link to their current contracts and focused internal
documentation as they are introduced.
ConfigKit currently contains the root `configkit` package, which establishes the
module and package documentation boundary. Its implemented public primitives are:
- immutable semantic `Path` construction and strict JSON Pointer parsing;
- detached provenance and diagnostic record types with empty `Metadata` queries;
- 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`;
- the sole upstream YAML integration in `internal/yaml`, which translates validated
input into that owned tree and exposes no parser types outside its boundary;
- 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;
- 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`;
- 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
`load_metadata.go`;
- recorded application defaulting and normalization in `resolution.go`, including
explicitness queries, helper verification, structural mutation audit, coalesced
intent, private before/after snapshots, and resolution metadata integration;
- post-resolution schema-aware report collection and validator mutation detection in
`validation.go`, completing the public callback lifecycle after composed input;
- opaque typed environment references and per-load name resolution in
`secret_reference.go`, including credential-free projection/cloning and private
validator-state auditing;
- 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`;
- 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
documentation-link and public import-boundary gates owned by root-package tests.
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,
metadata, secret classification, redacted effective rendering, historical
explanation, and ownership behavior.
The [consumer adoption guide](../consumers/README.md) owns current application-fit
and migration guidance.
The source-extraction inventory records the application foundations used by the
implementation:
- [Extraction inventory](extraction.md)
The [architecture policy](../policy/architecture.md) owns binding boundaries and
invariants. The [initial target design](../roadmap/initial.md) describes planned
behavior; it is not an inventory of implemented features. See the
[development guide](../development.md) for contributor orientation and validation.
invariants. The [initial target record](../roadmap/initial.md) links current feature
owners and preserves deferred scope; it is not an implementation inventory. See the
[development guide](../development.md) for contributor orientation and validation
and the [release procedure](../release.md) for source-candidate gates.

View File

@@ -7,10 +7,10 @@
## Purpose and applicability
ConfigKit is an opinionated Go library for consistent YAML configuration loading
and resolution across consuming applications. This policy governs contributions
now; it does not claim that the target features are implemented. The
[component overview](../internal/overview.md) owns implementation inventory and
[initial roadmap](../roadmap/initial.md) owns unimplemented target behavior.
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.
## Success criteria
@@ -48,8 +48,8 @@ existing primitives cannot accommodate it cleanly, and show how the extension
preserves a coherent model. Application-specific interpretation and validation
remain appropriate. Reuse Narratio's applicable infrastructure under these
boundaries, and keep simple consumers independent of optional composition
features. The [consumer API design](../roadmap/initial.md#consumer-api-design)
describes the proposed surface; this policy does not claim it is implemented.
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
implemented surface against representative simple and complex shapes.
## Responsibility boundaries
@@ -103,12 +103,12 @@ decoding. Preserve presence and source information through processing. Ordinary
values must not be used to infer whether input was absent. Final configuration,
metadata, and effective inspection must agree, including application resolution
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
Safe historical values and an explanation renderer belong to the initial target,
Safe historical values and an explanation renderer are part of the implemented contract,
including superseded/deleted paths and effective source attribution. Inspection
must agree with the final typed snapshot and apply historical sensitivity rules.
See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
Detailed syntax and operation tables belong to the target roadmap until their
current configuration contract is implemented.
Detailed syntax and operation tables belong to the
[current configuration contract](../config.md).
## Ownership and confidentiality
@@ -136,6 +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 roadmap owns the complete deferred-feature inventory. Significant boundary
The [initial target record](../roadmap/initial.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

@@ -62,24 +62,24 @@ handling, not secret values.
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
| Release procedure | `docs/release.md`, when operational | Exact maintainer commands, prerequisites, supported targets, and verification |
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
| 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 |
| 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 | `docs/roadmap/initial.md` | Unimplemented end-state and deferred feature scope |
| 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 gaps | `docs/roadmap/release-readiness.md` | Outstanding release prerequisites and closure evidence |
| Release readiness evidence | `docs/roadmap/release-readiness.md` | Closed preparation gaps and outstanding candidate/publication evidence until roadmap retirement |
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |
A listed future owner is not a requirement to create an empty file now. Create
API, consumer, example, and component documents when the corresponding behavior
A listed owner is not a requirement to create an empty file before it has useful
content. Create API, consumer, example, and component documents when the corresponding behavior
exists and there is useful content to own. Link only to existing files; use plain
code paths for planned files. Release prerequisites have the explicit temporary
exception recorded in the release policy.
code paths for planned files. Unvalidated candidate and publication work has the
explicit temporary exception recorded in the release policy.
ConfigKit has no owned HTTP service, application CLI, or runtime operations
interface. Do not create those reference documents for consuming applications.

View File

@@ -222,17 +222,21 @@ restating or redefining its requirements.
### Temporary bootstrap deferral
The release procedure (`docs/release.md`) and complete release-check command are
not yet present because ConfigKit has no Go module or implementation to validate.
Their creation is temporarily deferred during initial development. This exception
permits implementation work; it does not permit publishing a release without
any gate required above.
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: no release may be published until the
[release-readiness gaps](../roadmap/release-readiness.md) are closed, the runbook
and checker exist, and the exact candidate passes all applicable gates. The
maintainer preparing the first release owns closure. Remove this temporary
exception when readiness is verified. Any other exception requires a documented repository constraint, rationale,
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.

View File

@@ -39,7 +39,7 @@ boundaries. No credential bytes may appear in inspection or failure fixtures'
expected output; use disposable canaries to prove non-disclosure.
Baseline commands belong in the [development guide](../development.md). Release
gates belong in the [release policy](release.md) and its eventual runbook. This
gates belong in the [release policy](release.md) and [maintainer runbook](../release.md). This
policy does not imply that those tools or tests already exist.
## Every test has a cost

240
docs/release.md Normal file
View File

@@ -0,0 +1,240 @@
# ConfigKit Release Procedure
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
ConfigKit publishes one source-only Go module: the immutable source tag and the
matching checked-in release note are the deliverables. There is no application
binary, container image, generated package, or separate registry publication.
## Release identity and support
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
- Primary branch: `main`
- Authoritative remote: `origin`, expected to fetch from
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
- Minimum Go language/toolchain line: Go 1.25, tested with a supported patched
Go 1.25 toolchain and one newer supported toolchain
- Build targets: Linux and macOS on amd64 and arm64
The automated checker cross-builds all four targets. Before release, the maintainer
must also obtain native test, race, symlink, and permission-fixture evidence on Linux
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
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:
```sh
git remote get-url origin
git ls-remote origin HEAD
```
The remote URL must equal the authoritative URL above. Select the latest supported
patch in the Go 1.25 line and a newer supported toolchain. The values below are the
toolchains used while this runbook was introduced; update them to supported patch
releases during candidate preparation and record the actual versions in the release
note or validation record.
```sh
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.5
export CONFIGKIT_NEWER_TOOLCHAIN=go1.26.7
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go version
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go mod download
```
Those commands explicitly acquire toolchains and dependencies. The checker itself
sets `GOPROXY=off` and fails if its required cache is incomplete.
## 2. Select the version and review compatibility
Choose an unused stable version and compare the candidate with the previous tag.
For the first release, review the whole public surface and current contracts.
```sh
release_version=v0.1.0
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
printf 'remote tag already exists: %s\n' "$release_version" >&2
exit 1
else
test $? -eq 2
fi
git log --oneline --decorate --no-merges
```
Review exported Go signatures and ownership, YAML and scalar behavior, precedence,
diagnostic codes, minimum Go version, and platform support. Before `v1.0.0`, any
intentional incompatible minor-release change must be explained with consumer action;
patch releases must remain compatible within their minor line. Review the maintained
consumer examples and the migration guide even when signatures did not change.
Reassess `go.yaml.in/yaml/v4` rather than using `@latest` implicitly:
```sh
go list -m -versions go.yaml.in/yaml/v4
go list -m -json go.yaml.in/yaml/v4@latest
```
If changing the explicit pin, make that a reviewed dependency change and rerun the
complete checker, especially parser policy, limits, diagnostics, and rendering tests.
If retaining a prerelease pin, record that decision in Compatibility.
## 3. Write the release note
Create `docs/releases/$release_version.md` before selecting the candidate. It must
begin with `# ConfigKit $release_version` and contain `Summary`, `Compatibility`,
`Upgrade`, and `Changes` sections. Use “No action required” where appropriate. Link
to current canonical contracts instead of copying their definitions. Do not create a
release note until there is a real candidate and reviewed content.
## 4. Validate the source candidate
Run the complete checker from the repository root after dependencies have been
acquired. It validates module manifests in a disposable copy, both supported Go
toolchains, offline default tests, the native race detector, vet, native builds,
CGO-disabled cross-builds, formatting and whitespace (including untracked files),
examples, package isolation, and local documentation links.
```sh
./scripts/release-check.sh
```
`go vet` is the repository's static-analysis baseline. No additional linter is
claimed. Links to current ConfigKit files and headings are strict. Recorded links to
sibling consumer source are checked when those checkouts exist and are otherwise
inapplicable in an isolated clone; their immutable revisions remain recorded in the
extraction inventory.
Complete these manual gates and record their evidence with the candidate:
- The maintainer reviews compatibility and downstream migration impact against
`docs/config.md`, Go API comments, and the consumer guide.
- The maintainer inspects `git status --short`, `git diff`, and the candidate file
list for credentials, private data, generated output, unintended files, and
unrelated changes.
- The maintainer records successful native checker runs on Linux and macOS, including
filesystem capability tests. If either host is unavailable, release remains
blocked unless platform support is formally narrowed in current documentation.
- The maintainer removes completed, obsolete documents under `docs/roadmap/`, repairs
inbound links, and reruns the checker. ADR rationale remains historical.
- The maintainer reviews the exact version-matched release note for accuracy and
confirms it describes only deliverables that will actually be published.
## 5. Commit, push, and record the candidate
Commit all candidate source, contract, example, roadmap-retirement, and release-note
changes, rerun the checker against that clean commit, then push only `main` and record
the exact commit in Git metadata local to the clone:
```sh
git add .
git commit --message "Prepare ConfigKit $release_version"
./scripts/release-check.sh
test -z "$(git status --porcelain=v1 --untracked-files=all)"
git push origin HEAD:refs/heads/main
git rev-parse HEAD | tee .git/configkit-release-candidate
candidate_commit=$(cat .git/configkit-release-candidate)
```
If review changes the source after this point, create and validate a new candidate;
do not reuse the recorded commit.
## 6. Run publication guards
Immediately before tagging, fetch the authoritative branch and tags, restore the
recorded variables if this is a new shell, and execute every guard:
```sh
release_version=v0.1.0
candidate_commit=$(cat .git/configkit-release-candidate)
git fetch --prune origin main
git fetch --tags origin
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain=v1 --untracked-files=all)"
test "$(git rev-parse HEAD)" = "$candidate_commit"
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
test -f "docs/releases/$release_version.md"
test "$(sed -n '1p' "docs/releases/$release_version.md")" = "# ConfigKit $release_version"
for heading in Summary Compatibility Upgrade Changes; do
grep -Fxq "## $heading" "docs/releases/$release_version.md"
done
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
printf 'remote tag already exists: %s\n' "$release_version" >&2
exit 1
else
test $? -eq 2
fi
```
Confirm the recorded automated and manual approvals apply to exactly
`$candidate_commit`. A successful older run is not evidence for a changed commit.
## 7. Tag and publish the exact ref
Create one annotated tag at the guarded commit, verify its peeled target, and push
only that ref. Do not use `git push --tags`.
```sh
git tag --annotate "$release_version" --message "ConfigKit $release_version" "$candidate_commit"
test "$(git rev-list -n 1 "$release_version")" = "$candidate_commit"
git push origin "refs/tags/$release_version:refs/tags/$release_version"
```
The pushed tag completes the declared source-only publication. No Gitea release
object or binary upload is required.
## 8. Verify publication and clean consumer retrieval
Verify the authoritative tag and then retrieve that exact version into a new module
without a local `replace` directive. Private-host credentials must already be
configured. Use direct origin retrieval unless an explicitly approved internal Go
proxy is the publication channel.
```sh
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}')
test "$remote_tag_commit" = "$candidate_commit"
consumer_dir=$(mktemp -d)
(
cd "$consumer_dir"
go mod init example.invalid/configkit-release-check
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version"
cat > main.go <<'EOF'
package main
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
func main() {
_ = configkit.NoSource()
}
EOF
gofmt -w main.go
go test ./...
go build ./...
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit
)
rm -rf -- "$consumer_dir"
```
The final `go list` output must show the exact module path and `$release_version`.
Record the result with the release evidence.
## 9. Handle failures without replacement
Before publication, fix the candidate, rerun every gate, commit, push, and record a
new exact candidate. An unpublished local tag may be deleted only after verifying it
was never pushed or used by another release channel.
After publication, never move, delete, or recreate the tag and never replace its
release note. Correct the defect on `main`, choose a new semantically appropriate
version, and repeat this procedure. A supported advisory or deprecation mechanism
may warn consumers about the old version but cannot change its contents.

View File

@@ -2,7 +2,8 @@
## Status and ownership
Status: ready for implementation; all stages are pending.
Status: implementation complete; Stages 1–22 are complete. Release-candidate
selection and publication remain separate maintainer work.
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
ascending order. Each stage depends on every earlier stage being complete.
@@ -84,7 +85,7 @@ the adapter, YAML-policy, diagnostics, limits, and rendering regressions. A fina
upstream version is preferred when it passes those checks; if the RC is retained,
record that decision in release readiness and compatibility review.
Follow the feature's [upstream isolation contract](initial.md#yaml-dependency-and-public-api-isolation).
Follow the feature's [upstream isolation contract](../policy/architecture.md#public-api-and-dependency-direction).
Translate upstream API changes internally without changing ConfigKit declarations
or consumer requirements. Never re-export upstream aliases/options/plugins, expose
parser errors through wrapping, or pass parser-created objects through `any`.
@@ -96,8 +97,8 @@ unverified upstream versions or add a selectable-parser framework.
### Updated extraction map
Use the source revisions and adaptation policies owned by the feature's
[foundation](initial.md#existing-implementation-foundation) and
[secret bootstrap](initial.md#secret-references-and-explicit-bootstrap) sections.
[foundation](../internal/extraction.md) and
[secret bootstrap](../config.md#typed-secret-references) sections.
Paths below are relative to the named sibling repository. Reinspect them in
Stage 1 and record the exact revision actually copied. The maintainer is the sole
author of the relevant application code and has authorized its reuse and relicensing
@@ -153,9 +154,9 @@ syscall-level confinement, path discovery, or a public filesystem-provider API.
### Secret, override, and history implementation bounds
Follow the feature contracts for [secrets](initial.md#secret-references-and-explicit-bootstrap),
[CLI overrides](initial.md#cli-overrides), and
[explanation](initial.md#historical-value-explanation). Introduce these public APIs
Follow the feature contracts for [secrets](../config.md#typed-secret-references),
[CLI overrides](../config.md#explicit-cli-overrides), and
[explanation](../config.md#historical-value-explanation). Introduce these public APIs
only in Stages 16–19. Earlier stages cover ordinary types and file/profile layers;
they must not expose an unresolved secret as a completed load or silently accept
an override option. Extend the shared descriptor/projection graph for `Secret`
@@ -282,10 +283,28 @@ rendering must not reveal hidden value histories.
## Stage 1 — Establish the module and extraction baseline
Status: pending.
Status: complete.
Read: feature [foundation](initial.md#existing-implementation-foundation),
[consumer fit](initial.md#consumer-fit-and-intentional-migrations), all policies.
Completion evidence (2026-09-06):
- Added module `gitea.maximumdirect.net/eric/ConfigKit` at Go 1.25.0 and the
documented root `configkit` package, with no loader API or runtime dependency.
- Added the [source extraction inventory](../internal/extraction.md), recording the
clean Narratio revision `b68435128be1e24b94fc7ec259d6e9093669fd99` and
WeatherReporter revision `5d306a202471e10fe98923a1df27d0b0fce5c666`, assigned
symbols/cases, adaptations, and application-only behavior. No sibling checkout
is a build dependency and no fixture was needed at this boundary.
- Updated the [development baseline](../development.md), [component
inventory](../internal/overview.md), and repository status. The parser remains
planned at `go.yaml.in/yaml/v4 v4.0.0-rc.6`; its module requirement remains
deferred until the real adapter in Stage 3.
- Verified `go test ./...`, `go vet ./...`, `go build ./...`, `git diff --check`,
changed local links, and the introduced file set with Go 1.26.7 on linux/amd64.
No deviations were required. Paths, diagnostics, parsing, and all later behavior
remain assigned to subsequent stages.
Read: feature [foundation](../internal/extraction.md),
[consumer fit](../consumers/README.md), all policies.
Tasks:
@@ -314,10 +333,28 @@ Acceptance:
## Stage 2 — Implement semantic paths and safe diagnostic records
Status: pending.
Status: complete.
Read: feature [paths](initial.md#paths-and-presence),
[diagnostics](initial.md#provenance-diagnostics-and-redaction), public metadata API.
Completion evidence (2026-09-06):
- Implemented immutable key/index paths, strict JSON Pointer parsing, canonical
spelling, parsed numeric-token preservation, and private traversal intent in
[`path.go`](../../path.go).
- Implemented detached metadata/provenance records and empty/unknown query behavior
in [`records.go`](../../records.go) and [`metadata.go`](../../metadata.go).
- Implemented canonical diagnostic ordering, related-location normalization,
control-safe rendering, short/error writer propagation, and defensive
`LoadError` access in [`diagnostic.go`](../../diagnostic.go). No raw values,
parser errors, source parsing, or loading surface was introduced.
- Added focused tests for escaped slash/tilde/empty/dotted/numeric keys, literal
dot/percent tokens, malformed escapes, builder immutability, detached nested
records, `errors.As`, ordering independence, control escaping, and failing/short
writers. Verified `go test ./...`, `go vet ./...`, `go build ./...`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
metadata population and configuration lifecycle behavior remain later work.
Read: feature [paths](../config.md#layered-metadata),
[diagnostics](../config.md#safe-failures), public metadata API.
Tasks:
@@ -346,10 +383,33 @@ Acceptance:
## Stage 3 — Extract source reading and enforce YAML policy
Status: pending.
Status: complete.
Read: feature [YAML policy](initial.md#yaml-and-scalar-policy),
[source constructors](initial.md#entry-point-and-reusable-application-definition).
Completion evidence (2026-09-06):
- Added opaque source constructors and bounded file/byte/reader acquisition in
[`source.go`](../../source.go), including byte snapshots, regular-file checks,
deterministic I/O codes, caller-owned reader lifetime, and one atomic claim
shared by source copies. `NoSource` creates no source record.
- Pinned `go.yaml.in/yaml/v4 v4.0.0-rc.6` solely behind `internal/yaml` and added
the parser-independent ordered tree in `internal/document`. The adapter uses the
streaming loader, checks for a second document explicitly, applies a calibrated
private parser depth plugin, enforces owned depth/node limits, preserves numeric
spelling and locations, and returns only value-free internal issues.
- Generalized the strict-source/document cases recorded in the [extraction
inventory](../internal/extraction.md), created the [current configuration input
contract](../config.md), and added an AST import-boundary regression. No typed
decoder, composition, filesystem graph, or public `Load` was introduced.
- Tests cover real file failures and reopening, byte ownership, fragmented/error
readers, exact/overflow byte limits, concurrent copied-source claims, all YAML
policy categories, trailing/empty documents, duplicate locations, lexical wide
numbers, block/flow depth calibration, node limits, safe parser errors, and
adapter isolation. Verified `go test ./...`, `go vet ./...`, `go build ./...`,
`go mod tidy -diff`, and `git diff --check` with Go 1.26.7 on linux/amd64. No
deviations were required; typed binding and decoding remain later work.
Read: feature [YAML policy](../config.md#yaml-documents),
[source constructors](../config.md#single-source-loading).
Start with the extraction map's parser and strict-loader cases; the old
second-document acceptance defect is already fixed in Narratio.
@@ -403,10 +463,30 @@ Acceptance:
## Stage 4 — Build the reusable schema binding graph
Status: pending.
Status: complete.
Read: feature [typed decoding](initial.md#typed-decoding) and
[type/projection contract](initial.md#supported-types-and-projection).
Completion evidence (2026-09-06):
- Implemented a per-operation private descriptor graph in [`schema.go`](../../schema.go)
for structural roots, ordinary/named scalars, duration and paired text scalars,
pointers, structs, string-keyed maps, slices, arrays, dynamic values, and recursive
definitions. No global cache or application method execution is involved.
- Implemented declared-field YAML binding, ignored/private fields, explicit struct/
pointer/map inlining, ambiguity and tag validation, one inline map, secret-tag
retention, and schema-aware key/index path traversal. Runtime default collisions,
cyclic values, cloning, projection, and decoding remain assigned to later stages.
- Added the [current schema contract](../config.md#application-schema-binding) and
updated the component inventory and target-roadmap ownership links.
- Focused tests cover nested and inline conflicts, ignored/private fields, maps of
structs, named scalars, pointer text method sets, missing codec pairs, root and
unsupported-kind rejection, recursive descriptors, side-effect freedom, dynamic
and inline-map paths, parsed numeric indices, and array bounds. Verified
`go test ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
typed value operations remain later work.
Read: feature [typed decoding](../config.md#defaults-and-typed-decoding) and
[type/projection contract](../config.md#application-schema-binding).
Tasks:
@@ -432,10 +512,33 @@ Acceptance:
## Stage 5 — Implement canonical projection and detached copies
Status: pending.
Status: complete.
Read: feature [ownership](initial.md#recorded-resolution-and-validation) and
[type/projection contract](initial.md#supported-types-and-projection).
Completion evidence (2026-09-06):
- Added descriptor-driven canonical typed projection and independently owned cloning
in [`projection.go`](../../projection.go), without YAML serialization or a second
binding model. Zero values, nil/empty distinctions, exact integers, destination-
precision finite floats, durations, text scalars, dynamic concrete values, and
recursive acyclic objects retain their configuration meaning.
- Added active-graph cycle detection, private depth/node enforcement, safe custom
codec and dynamic-type failures, zeroing of ignored state, reconstruction of text
scalars into fresh instances, and non-preservation of shared pointer identity.
- Added canonical-tree structural comparison with semantic mapping additions,
removals and replacements, cross-kind replacement, and whole-sequence atomicity.
The [current ownership contract](../config.md#typed-projection-and-ownership) and
component inventory now own the implemented behavior.
- Focused tests mutate every source alias after cloning, cover nil versus empty,
wide integers, duration/text canonicalization, recursive acyclic values and cycles,
unsupported dynamic types, non-finite floats, value-free codec failures, inline
physical members/conflicts, projection bounds, ignored state, and meaningful
comparison paths without index-wise sequence matching. Verified `go test ./...`,
`go vet ./...`, `go build ./...`, `go mod tidy -diff`, and `git diff --check` with
Go 1.26.7 on linux/amd64. No deviations were required; defaults and typed decoding
remain later work.
Read: feature [ownership](../config.md#recorded-application-resolution) and
[type/projection contract](../config.md#application-schema-binding).
Tasks:
@@ -461,9 +564,33 @@ Acceptance:
## Stage 6 — Decode scalar and container values over defaults
Status: pending.
Status: complete.
Read: feature [defaults/null](initial.md#static-defaults-and-null) and typed decoding.
Completion evidence (2026-09-06):
- Added schema-driven typed decoding in [`decode.go`](../../decode.go), starting
from the Stage 5 detached clone and returning only private typed state, input
presence, projected baseline/final trees, and per-path default/base origins.
- Implemented recursive mapping retention, atomic sequence replacement, exact array
lengths, pointer-object zero allocation versus non-nil retention, nullable-shape
clearing, strict nested map values, inline-map decoding, and unknown-field errors.
- Added checked destination-width integers and floats, dynamic `int64`/`float64`,
named scalars, duration strings, paired text codecs, safe value-free failures, and
collection of independent decode errors at original semantic paths/coordinates.
The [current configuration contract](../config.md#defaults-and-typed-decoding) and
component inventory now own this behavior.
- Adapted Narratio's fresh-default, presence, strict nested-type, and repeated-load
cases, replacing custom YAML hooks and pointer/zero guesses with tree-derived
presence; explicit empty and null are retained and equal-valued input is explicit.
Focused coverage includes the defaults operation table, new/existing map entries,
empty mappings/sequences, nil/non-nil pointer objects, default isolation, numeric
range and coercion failures, integer durations, inline collisions, nested unknown
fields, coordinates, safe codec failures, and independent repeated results.
Verified `go test ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`,
and `git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
public loading and schema-version checks remain later work.
Read: feature [defaults/null](../config.md#defaults-and-typed-decoding) and typed decoding.
Tasks:
@@ -492,11 +619,41 @@ Acceptance:
## Stage 7 — Assemble single-source loading and schema-version checks
Status: pending.
Status: complete.
Read: feature [lifecycle](initial.md#resolution-lifecycle),
[version hook](initial.md#schema-checking-and-secret-classification),
[results](initial.md#results-errors-and-inspection).
Completion evidence (2026-09-06):
- Added the public generic `Load`, `Definition`, `Options`, `Result`, version,
filesystem-context, override, validation-context, and reserved resolution
declarations in [`load.go`](../../load.go) and [`validation.go`](../../validation.go).
The active lifecycle validates options, parses one source, rejects reserved
directives, builds the schema, checks an optional application version, creates
fresh defaults, decodes, and captures detached state.
- Added absent/null/string version handling, non-string rejection, exact/nearest
primary and related input locations, safe application reports, warning retention,
and generic value-free hook failure only when no explicit error was reported.
Defaults run only after version success and exactly once for a continuing load.
- Populated effective base/default `Metadata` and chronological definition history
in [`load_metadata.go`](../../load_metadata.go), with explicit and null state,
synthetic default origins, base coordinates, equal-valued input records, and
defensive result/source/diagnostic copies. `NoSource` performs the same schema,
version, defaults, decode, and projection lifecycle without a source record.
- Added explicit `options.unavailable` gates for resolution, semantic validation,
imports, active profiles, filesystem context, overrides, and secret classification;
invalid `NoSource` combinations and `_configkit` directives fail rather than being
ignored. The [current single-source contract](../config.md#single-source-loading),
package documentation, README, and component inventory own the implemented API.
- Integration tests cover File/Bytes/Reader/NoSource, schema-version states and
warnings, related locations, raw hook-error suppression, fresh-default ordering,
metadata agreement and detachment, source lists, unavailable options, reserved
directives, and nil results for source/schema/version/decode failures. Verified
`go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
`go mod tidy -diff`, and `git diff --check` with Go 1.26.7 on linux/amd64. No
deviations were required; resolution and semantic validation remain later work.
Read: feature [lifecycle](../config.md#single-source-loading),
[version hook](../config.md#application-schema-binding),
[results](../config.md#safe-failures).
Tasks:
@@ -527,9 +684,38 @@ Acceptance:
## Stage 8 — Implement recorded application resolution
Status: pending.
Status: complete.
Read: feature [resolution callbacks](initial.md#resolution-and-validation-callbacks).
Completion evidence (2026-09-06):
- Implemented per-load `Resolution`, `IsExplicit`, `Record`, `Default`, `Normalize`,
`Error`, and `Warning` in [`resolution.go`](../../resolution.go), and enabled the
resolver in `Load` after successful decode while leaving semantic validation gated.
- Added schema-aware path/kind/dependency checks, safe reason requirements, dynamic
and typed helper projection, final helper/path consistency checks, before/after
structural comparison, exact/ancestor record coverage, and distinct safe failures
for invalid values, targets, records, and unrecorded changes.
- Coalesced repeated path intent deterministically, retaining unique reasons and
dependencies, using the last helper value, and promoting mixed operations to
normalization. Equal-valued derived intent remains recorded; map copy/writeback,
container coverage, deletions, and new dynamic/map descendants are supported.
- Integrated resolution origins and history without changing surviving input
explicitness, retained private detached before/after snapshots, sorted records by
semantic path, and made retained callback-context use panic with fixed value-free
text. Resolver errors are suppressed unless explicitly reported through the
context; warnings remain on successful results. The
[current resolution contract](../config.md#recorded-application-resolution) owns
this behavior.
- Focused tests execute the timeout/artifact consumer shape, explicit null/zero and
absent-baseline behavior, normalization, map writeback, equal and repeated helper
intent, stable map-derived record order, subtree removal, snapshot detachment,
unrelated targets, invalid paths/kinds/values, unrecorded mutation, safe hook
failures, and closed resolution/version contexts. Verified `go test ./...`,
`go test -race ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
semantic validation remains later work.
Read: feature [resolution callbacks](../config.md#recorded-application-resolution).
Tasks:
@@ -558,10 +744,40 @@ Acceptance:
## Stage 9 — Add semantic validation and finish the single-source lifecycle
Status: pending.
Status: complete.
Read: feature [diagnostic callbacks](initial.md#resolution-and-validation-callbacks)
and [schema reports](initial.md#schema-checking-and-secret-classification).
Completion evidence (2026-09-06):
- Reused `Validation` for post-resolution application reports in
[`validation.go`](../../validation.go), with `validate.error`,
`validate.warning`, and `validate.invalid_path` classifications. Omitted declared
fields and missing string-map/dynamic keys remain valid report paths; unknown
struct fields and malformed primary or related paths fail safely.
- Attached exact or nearest input locations to primary and related reports, retained
independent errors and warnings, continued through warnings, and returned no
configuration on any semantic error. Version, decode, and resolution failures stop
before later hooks.
- Added before/after typed projection around the validator. Any net scalar,
container, or nested mutable-state change, and any newly unprojectable value,
returns `validate.mutation` without publishing the mutated object. Callback
contexts close after invocation and callback panics remain programming failures.
- Removed the semantic-validator options gate, finalized metadata only after
successful resolution/validation, and retained defensive configuration snapshot,
metadata, diagnostic, and source ownership. The
[current semantic-validation contract](../config.md#semantic-validation) owns the
completed single-source lifecycle and application/runtime-state boundary.
- Added compiling executable examples in [`example_test.go`](../../example_test.go)
for minimal loading, defaults-only operation, related-path validation, actionable
schema-version guidance, and resolution followed by validation. Focused tests cover
multiple reports, missing/dynamic paths, related/nearest locations, invalid paths,
validator mutation, hook ordering and stop points, warnings, and final value/
explicitness/origin/history agreement. Verified `go test ./...`,
`go test -race ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
imports and profiles remain deliberately unavailable.
Read: feature [diagnostic callbacks](../config.md#recorded-application-resolution)
and [schema reports](../config.md#application-schema-binding).
Tasks:
@@ -586,9 +802,29 @@ Acceptance:
## Stage 10 — Implement strict document composition
Status: pending.
Status: complete.
Read: feature [composition table](initial.md#composition-and-overlay).
Completion evidence (2026-09-06):
- Adapted Narratio's additive claim validation and recursive mapping join into the
private `composeDocuments` boundary over ConfigKit-owned document trees. Present
equal or unequal scalars, nulls, sequences, mapping/value kind changes, and nested
duplicate entries return `compose.conflict`; mapping pairs alone recurse.
- Changed the extracted empty-map behavior to ConfigKit's operation table: empty
mappings add no children, compose with other mappings, and retain their container
location in composite base origins. Application defaults never enter the composer.
- Preserved source/declaration traversal in detached ordered claim records and used
it only for assembled order and exact primary/related diagnostics. Conflicts across
three or more inputs report every claimant, while shared containers aggregate all
contributing locations instead of selecting an arbitrary owner.
- Cloned every retained branch and verified mutations of a composed result cannot
affect any input. Focused tests cover disjoint keyed joins, operation categories,
ancestor and nested conflicts, equal values, nulls, sequences, empty mappings,
exact locations, all claimants, invalid boundaries, and nonmutation. The
[current composition contract](../config.md#strict-document-composition) owns the
implemented semantics; public import wiring remains deferred to subsequent work.
Read: feature [composition table](../config.md#strict-document-composition).
Extract Narratio's additive composer and conflict tests before writing equivalent
code. Change empty-map expectations to the feature table and retain exact locations.
@@ -614,9 +850,40 @@ Acceptance:
## Stage 11 — Implement explicit file-import traversal
Status: pending.
Status: complete.
Read: feature [import graph](initial.md#explicit-imports-and-source-identity).
Completion evidence (2026-09-06):
- Added strict recursive `_configkit.imports` parsing with exactly one `path` or
recognized `glob`, optional booleans, unknown-shape rejection, root-only placement,
and detached directive removal. Glob declarations return the deliberate temporary
`import.unsupported` gate for the next owning work.
- Implemented declaration-order depth-first path traversal with canonical symlink
identity, relative containment checks, and canonical root-relative access through a
ConfigKit-owned `os.Root`. File inputs infer their canonical base/root; labeled
byte and reader inputs require explicit absolute base/root context and never infer
paths from URI-like labels.
- Kept active and visited indexes separate, reporting complete cycle import sites and
both diamond/alias inclusion routes. Canonical aliases repeat, while hard links at
distinct paths remain separate sources. Required missing paths, directories,
unreadable targets, escapes, and canonicalization failures remain errors; optional
suppresses only nonexistence.
- Assigned deterministic source IDs while reading each accepted source once, enforced
per-source and aggregate bytes, aggregate input nodes, source count, and active-edge
depth, and retained import-site/source records on failures. Imported fragments are
rejected when they claim the application version path or a non-mapping ancestor,
before strict composition.
- Wired composed input and multi-location origins through `Load`, version checking,
strict decoding, resolution, validation, and base metadata. Temporary-directory
tests cover nested/list-order traversal, composition conflicts, version placement,
directives, optional/required paths, explicit roots, cycles, diamonds, symlinks,
hard links, parse/decode attribution, file types, context rules, and reduced limit
boundaries. The [current path-import contract](../config.md#explicit-imports)
owns implemented behavior; glob expansion remains pending. Verified `go test ./...`,
`go test -race ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64.
Read: feature [import graph](../config.md#explicit-imports).
Extract the flat import loader and tests, then generalize its graph, identity and
containment rules. Do not treat its root-only declarations as ConfigKit's contract.
@@ -650,7 +917,36 @@ Acceptance:
## Stage 12 — Complete glob imports and filesystem option handling
Status: pending.
Status: complete.
Completion evidence (2026-09-06):
- Removed the temporary glob gate and added prevalidated Go `path.Match` semantics
for `*`, `?`, character classes, and escapes, with recursive unescaped `**`, URLs,
absolute paths, empty segments, and malformed classes rejected before any walk.
- Added a pattern-directed walker that resolves literal segments directly and reads
wildcard directories through positive bounded `File.ReadDir` batches. It charges
every examined entry, including nonmatches, canonicalizes and contains traversed
directories and final targets, sorts slash-relative matches, and bounds final
candidates against remaining source capacity before traversal.
- Reused the path-import DFS for every sorted match, preserving declaration ordering,
nested depth-first imports, canonical alias/repetition handling, hard-link path
identity, source IDs, composition, version placement, and all existing byte/node/
source/depth budgets. Optional glob declarations suppress only zero matches, never
invalid syntax, walk/permission failures, matched directories, or symlink escapes.
- Completed filesystem option validation independently of import/profile selection:
file inputs reject `BaseDir` and may select a containing root; byte/readers require
existing absolute base/root pairs whenever context is supplied; `NoSource` rejects
context while the reserved `default` profile remains inactive.
- Tests cover lexical and multi-declaration ordering, deterministic repeated loads,
overlapping matches, directories, hard links, contained absolute and escaping
symlink directories, required/optional no-match behavior, invalid patterns in empty
directories, escaped metacharacters, character classes, visible permission errors,
explicit `conf.d` imports without discovery, nonmatching-entry exhaustion, candidate
bounds, and filesystem combinations. The
[current import contract](../config.md#explicit-imports) owns the completed behavior.
Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
`go mod tidy -diff`, and `git diff --check` with Go 1.26.7 on linux/amd64.
Read: feature import syntax and source-context API.
@@ -686,9 +982,33 @@ Acceptance:
## Stage 13 — Implement profile overlays
Status: pending.
Status: complete.
Read: feature [profiles](initial.md#profiles), composition/overlay table.
Completion evidence (2026-09-06):
- Added a private non-aliasing recursive overlay that merges mappings by key and
atomically replaces scalars, sequences, empty sequences, nulls, and cross-kind
subtrees. Replacement deletes stale descendant origins and records every displaced
definition plus the parent operation for later history.
- Added exact selected-profile loading from `profiles/<name>.yml`, with constrained
names, inactive empty/`default` selection, canonical root containment, base-graph
duplicate rejection, deterministic profile source ownership, and shared aggregate
source/byte/node limits. No path selection, extension fallback, inheritance, or
implicit default file is supported.
- Applied profiles only after base import composition and version checking and before
defaults and strict decoding. Profiles reject `_configkit` at any depth, direct
version contributions, and scalar/null replacements of version ancestors; base
parse and version failures therefore cannot be masked.
- Tests cover recursive siblings, scalar/full/empty-sequence/null/cross-kind
replacement, lower defaults, profile source and origin attribution, removed-child
and stale-index history, selection errors, fixed extension, directives, version
placement, base aliases, parse ordering, filesystem context, symlink escape, input
budgets, and non-aliasing. The
[current profile contract](../config.md#selected-profiles) owns the behavior.
Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
and `git diff --check` with Go 1.26.7 on linux/amd64.
Read: feature [profiles](../config.md#selected-profiles), composition/overlay table.
Extract Narratio's overlay mechanics and profile tests; preserve the intentional
null/kind and profile-selection differences listed in the feature roadmap.
@@ -718,10 +1038,34 @@ Acceptance:
## Stage 14 — Verify layered provenance and final snapshot agreement
Status: pending.
Status: complete.
Read: feature [presence](initial.md#paths-and-presence),
[history](initial.md#provenance-diagnostics-and-redaction), metadata API.
Completion evidence (2026-09-06):
- Unified default, composed base, profile displacement, and resolution records in
the metadata history index. Container resolution now appends its operation to each
changed, added, or removed descendant while preserving the ancestor operation path
and path-specific previous origin; effective metadata still contains only the final
projected paths.
- Added a representative public `Load` workflow spanning a fresh default, selected
source, depth-first import, profile, derived default, mapping deletion, and
whole-sequence normalization. It verifies source order, equal-to-default
explicitness, null state, composite empty-mapping origins, chronological parent
replacement history, deduplicated reasons/dependencies, typed/final snapshot
agreement, and unchanged siblings.
- Verified nested metadata, source import locations, diagnostics, caller-owned
configuration, and private snapshots are detached. Repeated concurrent selected-
profile loads retain independent typed values, metadata, sources, and histories;
the race baseline passes.
- Promoted layered metadata semantics and concrete composite/synthetic origin cases
to the [current metadata contract](../config.md#layered-metadata), updated Go
reference comments, and recorded the adapted Narratio ownership/inspection cases.
Rendering, redaction, value snapshots, and explanation remain within later stages.
Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
`go mod tidy -diff`, and `git diff --check` with Go 1.26.7 on linux/amd64.
Read: feature [presence](../config.md#layered-metadata),
[history](../config.md#safe-failures), metadata API.
Use Narratio's effective ownership and inspection tests as extraction evidence.
Its source-only records need locations and complete historical operations.
@@ -748,10 +1092,39 @@ Acceptance:
## Stage 15 — Implement secret classification and effective rendering
Status: pending.
Status: complete.
Read: feature [redaction](initial.md#provenance-diagnostics-and-redaction),
[secret predicate](initial.md#schema-checking-and-secret-classification), result API.
Completion evidence (2026-09-06):
- Removed the dynamic-secret gate and combined descriptor `configkit:"secret"` roots
with a pure application path predicate over final and historical paths. Tagged
containers cover descendants, replaced/deleted definitions stay classified, and
resolution dependencies propagate sensitivity to a fixed point, including
container dependencies and cycles independent of record-map iteration order.
- Added `Result.WriteEffective` over the frozen resolved document. It clones and
redacts before crossing the adapter boundary, sorts mappings by decoded key,
preserves sequence order and nil/empty distinctions, and never invokes application
YAML hooks or text codecs during rendering. Caller mutation cannot change output;
there is no unredacted path or implicit logging.
- Added a private v4 dumper adapter with explicit two-space/noncompact-sequence
indentation, unlimited line width, Unicode, unique keys, block collections,
single-quote preference, LF endings, one document, and disabled start/end markers.
Secret roots render as the quoted string `'[REDACTED]'`; writer errors and short
writes propagate while internal dumper errors collapse to a fixed safe error.
- Tests cover tagged containers, dynamic current and overwritten paths, dependency
cycles, container dependencies, custom text scalars, application marshal-hook
isolation, resolved values, sorted/stable YAML, sequence order, nil/empty forms,
snapshot mutation isolation, writer failures, and canary non-disclosure through
parsing, decoding, returned hook errors, warnings, metadata, and ordinary/Go-syntax
record formatting. The
[current redaction contract](../config.md#secret-classification-and-effective-rendering)
owns the behavior. Historical-value explanation and secret references/bootstrap
remain later work. Verified `go test ./...`, `go test -race ./...`, `go vet ./...`,
`go build ./...`, `go mod tidy -diff`, and `git diff --check` with Go 1.26.7 on
linux/amd64.
Read: feature [redaction](../config.md#safe-failures),
[secret predicate](../config.md#application-schema-binding), result API.
Reuse Narratio's deterministic effective-output and non-disclosure cases. Render
from the owned typed projection, without its marshal/parse reconstruction.
@@ -791,9 +1164,38 @@ Acceptance:
## Stage 16 — Extract explicit secrets-directory bootstrap
Status: pending.
Status: complete.
Read: [secret bootstrap contract](initial.md#secret-references-and-explicit-bootstrap),
Completion evidence (2026-09-06):
- Added the separate, opt-in `LoadSecretsDir` startup boundary in
[`secrets.go`](../../secrets.go), with detached per-file results, stable status
values, safe `SecretsError` diagnostics, and a deterministic quoted report
renderer. `Load` remains process-state-free and has no secrets-directory option.
- Adapted WeatherReporter's read/apply separation, filename/type checks, exact
one-LF/CRLF removal, and failure cases together with Narratio's bounded reads and
existing-variable preservation. The [extraction inventory](../internal/extraction.md)
records both inspected revisions, reused symbols/cases, and the intentional
removal of overwrite, rollback, permission-mode, implicit configuration, and
logging policies.
- Implemented bounded immediate enumeration (1,024 entries), canonical directory
selection, sorted metadata validation, 1 MiB eligible-file and 8 MiB aggregate
reads, NUL rejection, full preflight before writes, and a per-write presence
recheck. Existing empty values are distinguished, skipped files are never opened,
and apply failures retain accurate partial progress without rollback.
- Added real filesystem/environment and private-seam coverage in
[`secrets_test.go`](../../secrets_test.go) for canonical ordering, selected
directory symlinks, newline/empty values, invalid names/types, all bounds,
missing/permission/read failures, no-write preflight failures, newly populated
variables, controlled apply failure, defensive copies, non-disclosure, and writer
failures. The [configuration contract](../config.md#explicit-secrets-directory-bootstrap)
documents startup-only mutation, non-atomicity, reporting blocked files, and
retaining reports alongside configuration results. Verified `go test ./...`,
`go test -race ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
environment-backed typed secret references remain later work.
Read: [secret bootstrap contract](../config.md#typed-secret-references),
architecture responsibility boundary, testing policy, and WeatherReporter's
`internal/config/secrets.go` and corresponding `config_test.go` cases; also
Narratio's `internal/app/secrets_env.go`, its tests, and bounded-read helper.
@@ -831,9 +1233,41 @@ Acceptance:
## Stage 17 — Add environment-backed typed secret references
Status: pending.
Status: complete.
Read: [secret reference contract](initial.md#secret-references-and-explicit-bootstrap),
Completion evidence (2026-09-06):
- Added the opaque `Secret` and `SecretEnv` API in
[`secret_reference.go`](../../secret_reference.go), with name/value/resolution
accessors, name-only JSON, and credential-free formatting for every `fmt` verb.
The zero value remains an absent reference and no public constructor can inject
resolved credential state.
- Extended the shared descriptor, decode, projection, clone, comparison, resolution,
and validation paths with a first-class secret-reference descriptor. References
project only their public names, cloned defaults are rebuilt unresolved, dynamic
`any` values cannot introduce secret semantics, and existing tag/predicate
classification continues to hide additionally sensitive names or derived data.
- Inserted deterministic per-load environment resolution after recorded application
resolution and before semantic validation. Only effective valid names are read,
each unique success/unset/empty outcome is cached for that load, required and
optional shapes receive path-specific `secret.*` diagnostics, and `Load` performs
no environment writes. Validation compares private reference state with the lookup
cache in addition to the ordinary projected mutation audit.
- Added focused coverage in
[`secret_reference_test.go`](../../secret_reference_test.go) for structs, maps,
pointers and sequences; successful and failed lookup deduplication; absent/null/
empty/invalid/unset cases; resolver-selected names; resolved-value validation;
same-name unresolved validator replacement; later-load environment changes;
stale resolved defaults; dynamic values; bootstrap blocked-empty integration; and
non-disclosure through fmt, JSON, effective projection, history, and diagnostics.
Added executable `ExampleSecretEnv` and updated the
[current secret-reference contract](../config.md#typed-secret-references), component
inventory, developer guide, and README. Verified `go test ./...`,
`go test -race ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
CLI overrides remain later work.
Read: [secret reference contract](../config.md#typed-secret-references),
shared descriptor/projection rules and lifecycle, Stage 16 report boundary.
Tasks:
@@ -867,9 +1301,43 @@ Acceptance:
## Stage 18 — Apply explicit CLI overrides above profiles
Status: pending.
Status: complete.
Read: [override contract](initial.md#cli-overrides), overlay/presence/version rules,
Completion evidence (2026-09-06):
- Activated the existing `Override`/`Options.Overrides` surface through the new
parser-neutral implementation in [`override.go`](../../override.go). It accepts
only built-in scalar values, `nil`, `[]any`, and `map[string]any`; preserves
scalar kinds and sequence order; sorts maps; rejects named/unsupported values,
nonfinite floats and cycles; and snapshots all accepted caller-owned state without
YAML reparsing or textual inference.
- Added full-record preflight for safe flag labels, non-root mapping paths,
sequence traversal, existing non-mapping ancestors, duplicates/overlaps,
`_configkit`, schema-version overlap, and fixed record/source/string-byte/node/
depth budgets. Valid records create deterministic synthetic CLI sources after
file and profile sources; failures identify those safe flag sources without raw
arguments or values.
- Generalized the existing non-aliasing overlay machinery across profile and CLI
layers. Overrides apply after the selected profile and before defaults/typed
decode, create missing mapping ancestors, recursively overlay mappings, replace
sequences atomically, and retain detached before/after snapshots plus displaced
origins even for equal-value operations. Layered history reconstruction now
preserves default/base/profile/CLI order and parent operations for descendants.
- Added lifecycle and boundary coverage in
[`override_test.go`](../../override_test.go) for defaults < imports/base < profile
< CLI < recorded normalization, followed by secret lookup and validation; explicit
zero/false/empty/list/null; defaults-only loads; map merge, whole-list replacement,
parsed numeric map keys, caller mutation, equal replacements, safe decode source
attribution, invalid paths/ancestors/overlaps/directives/version writes, value
types/cycles/nonfinite values, and bounds. Added executable `ExampleOverride` and
moved the normative behavior into the
[current override contract](../config.md#explicit-cli-overrides), explicitly
retaining general environment overlays as a non-goal. Verified `go test ./...`,
`go test -race ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
safe historical-value explanation remains later work.
Read: [override contract](../config.md#explicit-cli-overrides), overlay/presence/version rules,
source ordering, and the secret-reference boundary.
Tasks:
@@ -897,9 +1365,45 @@ Acceptance:
## Stage 19 — Expose safe historical-value explanation
Status: pending.
Status: complete.
Read: [explanation contract](initial.md#historical-value-explanation), redaction
Completion evidence (2026-09-06):
- Added detached `ValueSnapshot`, `ValueChange`, `SecretReferenceInfo`, and
`Explanation` records plus `Result.Explain` and `Result.WriteExplanation` in
[`explain.go`](../../explain.go). Effective/default/domain values use canonical
typed projection; base/profile/CLI events preserve policy-validated input, with
explicit absent, null, equal-write, replacement, and deletion states.
- Extended composition, profile, and CLI handling to retain immutable applications
in traversal/precedence order. Explanation replays those applications on demand
with mapping recursion and atomic sequence replacement, then applies the existing
audited resolution subtree deltas. This reconstructs container and descendant
histories without a full retained tree per field, including superseded input that
could not decode into the final destination type.
- Applied tag, predicate, and dependency-cycle sensitivity across final and
historical application paths before snapshot projection. Snapshots recursively
redact classified descendants, resolved `Secret` metadata retains only successful
effective environment names, and classified references omit that annotation.
Shared immutable nodes count once toward a fixed 4,000,000-node retained-history
budget; excess fails safely with `explain.limit_exceeded`.
- Added deterministic human rendering with canonical quoted pointer paths, effective
and composite source locations/labels, representation labels, chronological
before/after YAML, reasons, dependencies, and secret lookup attribution. Unknown
paths write nothing; writer errors and short writes propagate. Added executable
`ExampleResult_Explain` and moved normative behavior to the
[current explanation contract](../config.md#historical-value-explanation), keeping
bootstrap reporting explicitly separate.
- Added focused coverage in [`explain_test.go`](../../explain_test.go) for the
imported-default/profile sequence 4 → 8 → 1, CLI and normalization, equal writes,
removed paths, parent replacement, composite mappings, atomic sequences,
superseded invalid input, null/empty values, successful and classified references,
historical/dependency-cycle canaries, returned-record mutation, deterministic and
failing writers, structural-sharing budgets, and an end-to-end secrets-bootstrap/
load/explain flow. Verified `go test ./...`, `go test -race ./...`, `go vet ./...`,
`go build ./...`, `go mod tidy -diff`, and `git diff --check` with Go 1.26.7 on
linux/amd64. No deviations were required; consumer ergonomics remain later work.
Read: [explanation contract](../config.md#historical-value-explanation), redaction
rules, and history bounds above.
Generalize Narratio's semantic-record and inspection tests. They cover effective
@@ -933,7 +1437,56 @@ Acceptance:
## Stage 20 — Prove consumer ergonomics and migration contracts
Status: pending.
Status: complete.
Completion evidence (2026-09-06):
- Added the maintained [`examples/consumers`](../../examples/consumers) package and
sanitized local fixtures for four application shapes. The Narratio-like example
uses fresh defaults, partial imports, artifacts, recorded inherited defaults, and
an application model profile distinct from an optional ConfigKit profile. The
Notarius-like example uses canonical object bindings, a string version rule,
dynamic options, and range-checked `int64` conversion at the runtime boundary.
- Added WeatherReporter-like defaults-only and file loads, report maps, canonical
module objects, typed option unions, and shared loaded/constructed validation.
Application validation—not the ConfigKit schema—rejects explicit empty module and
path-template lists and null required module options. Recorded normalization acts
on detached values without mutating caller-owned constructed options. Added
independent ordered Seriatim-like speaker and autocorrect rule loaders while
retaining matching/replacement algorithms in the example package.
- Added executable examples for all four shapes and a WeatherReporter-shaped startup
flow. The startup explicitly renders `SecretsReport`, demonstrates an existing
empty variable blocking its file and a separate successful disposable lookup,
translates only an explicitly set `--units` flag into an `Override`, and renders
its explanation. It verifies ordinary `Load` preserves the process environment
and never uses a real credential.
- Generalized the assigned Narratio inspection/default/artifact outcomes and
WeatherReporter defaults, explicit-empty, loaded/constructed consistency, and
option-normalization cases. [`docs/internal/extraction.md`](../internal/extraction.md)
records the rechecked clean revisions, adapted cases, intentional differences,
and no-copy boundaries for Narratio `b68435128be1e24b94fc7ec259d6e9093669fd99`,
WeatherReporter `5d306a202471e10fe98923a1df27d0b0fce5c666`, Notarius
`77ae132e5609916da40dcd4fcc749c000a63cbac`, and Seriatim
`0fc92f364316c874d366b00cdc884a33e1900b25`. No sibling checkout is a build or test
dependency.
- Added negative migration fixtures for integer versions, string module shorthand,
explicit empty lists, explicit null required options, and strict unknown fields.
They fail with stable `version.type`, `decode.type`, `validate.error`, and
`decode.unknown_field` classifications at semantic paths without parser text.
Dynamic options bind directly without YAML reserialization or a generic subtree
conversion API.
- Added the [consumer adoption guide](../consumers/README.md), current canonical
module-options contract, README navigation, and component inventory. The guide
maps every feature-roadmap API sketch to an executable example or focused test,
describes removed plumbing versus retained domain code, and lists explicit
empty/null, object, version, integer, strict-YAML, environment-overlay, CLI, and
secret-reference migrations without claiming downstream repositories are migrated.
- Confirmed the example package directly imports only ConfigKit and the standard
library, then reviewed the exported surface and existing AST import-boundary test
for aliases, generic constraints, callbacks, record fields, and safe error chains.
Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
`go mod tidy -diff`, and `git diff --check` with Go 1.26.7 on linux/amd64. No
deviations were required; broader hardening remains later work.
Read: feature consumer-fit table, module options, external values, and both criteria.
@@ -981,7 +1534,40 @@ Acceptance:
## Stage 21 — Harden boundaries and verify independent loads
Status: pending.
Status: complete.
Completion evidence (2026-09-06):
- Added bounded fuzz targets `FuzzParsePath`, `FuzzParseDocument`, and
`FuzzCompositionAndOverlay` for canonical pointer round trips, accepted YAML
parse/write stability, composition order, overlay behavior, input immutability,
and detached results. Each target has a small deterministic seed set and explicit
byte, translated-node, and depth bounds; the exact 30-second, two-worker commands
are recorded in the [developer guide](../development.md#fuzz-validation).
- Added concurrent public-load coverage that shares one read-only `Definition` while
each defaults factory returns fresh maps and slices. The test exercises custom
text scalars, independently mutable results, and the frozen effective snapshot.
A second load-level race test confirms copies of one `Reader` source compete for
one atomic claim, producing exactly one success and one `source.reader_used`
error.
- Reviewed the existing exact-edge source-byte, aggregate import/profile byte/node,
parser depth/node, schema traversal, typed projection, explanation-history, and
writer-error tests. Those boundaries already cover consequential reads,
traversal, projection, redacted rendering, and historical rendering, so no new
public limits or duplicate edge tests were added. YAML anchors, aliases, and merge
keys remain rejected; there is no provider extension gate or raw-secret rendering
route.
- Ran the three documented fuzz commands for 30 seconds each with `-parallel=2`.
Path and YAML passed directly. The first composition campaign revealed that its
initial fuzz-only bounds could leave an expensive case active at campaign
shutdown; tightening them to 4 KiB per document, 256 translated nodes, depth 12,
and parser depth 24 made the exact rerun pass without changing product behavior or
requiring a regression corpus.
- Verified `go test ./...`, `go test -race ./...`, `go vet ./...`, `go build ./...`,
`go mod tidy -diff`, `git diff --check`, and a fresh
`GOTOOLCHAIN=local GOPROXY=off go test ./...` with Go 1.26.7 on linux/amd64.
Existing skips are explicit platform/filesystem-capability probes; no default test
reads credentials, network services, or developer-specific configuration.
Read: testing policy, ownership/projection, import limits, redaction guarantees.
@@ -1012,7 +1598,47 @@ Acceptance:
## Stage 22 — Prepare release validation and reconcile documentation
Status: pending.
Status: complete.
Completion evidence (2026-09-06):
- Added the [maintainer release procedure](../release.md) for the existing
`origin/main`, module `gitea.maximumdirect.net/eric/ConfigKit`, source-only stable
tags, Go 1.25 minimum and newer-toolchain validation, and Linux/macOS amd64/arm64
targets. It supplies candidate recording, clean branch/index/worktree and exact
remote-commit guards, version/release-note/tag checks, precise single-ref tag
publication, clean consumer retrieval without `replace`, and immutable correction
steps. Private-host access is a prerequisite without embedding credentials or
environment dumps.
- Added fail-fast [`scripts/release-check.sh`](../../scripts/release-check.sh). It
verifies cached dependencies and runs offline disposable-copy module hygiene,
default tests on Go 1.25.5 and Go 1.26.7, vet and native builds on both, a native
race run, CGO-disabled builds for linux/amd64, linux/arm64, darwin/amd64, and
darwin/arm64, repository-wide Go formatting and text whitespace including
untracked files, executable examples, consumer fixtures, public import isolation,
and local documentation links. Toolchain/dependency acquisition remains an
explicit preceding runbook step.
- Added `TestLocalDocumentationLinks`, which strictly verifies repository-local files
and Markdown headings while treating recorded sibling-source links as applicable
only when those optional checkouts exist. The runbook makes immutable extraction
revisions authoritative in isolated release clones.
- Reconciled README, development, configuration, consumer, architecture/testing/
documentation/release policies, component and extraction inventories, and ADR
navigation with current behavior. Reduced the implemented feature roadmap to a
target record linking current owners and retaining only foundations plus explicit
non-goals/deferred scope. The implementation plan and readiness record remain until
an actual candidate retires completed roadmap files as required by release policy.
- Reassessed the YAML v4 dependency with `go list -m -versions` and
`go list -m -json ...@latest`; `v4.0.0-rc.6` remained the newest available release
on 2026-09-06, so the tested explicit pin was retained. `go doc -all` and
`TestDependencyImportBoundary` confirmed public documentation is renderable and no
upstream YAML type/import leaks outside the private adapter or maintained examples.
- The complete checker passed on linux/amd64 with Go 1.25.5 and Go 1.26.7. The
[release-readiness evidence](release-readiness.md) keeps exact candidate/release
note, native macOS execution, compatibility/content approval, roadmap retirement,
authoritative remote/tag guards, and post-publication retrieval explicitly open
under the revised bootstrap exception. No tag, push, downstream migration, release
note, or publication occurred.
Read: release policy in full and [release-readiness gaps](release-readiness.md).

File diff suppressed because it is too large Load Diff

View File

@@ -2,24 +2,36 @@
## Status and ownership
ConfigKit is not release-ready. This document tracks outstanding release
prerequisites under the [temporary bootstrap exception](../policy/release.md#temporary-bootstrap-deferral).
It is not the library implementation plan and does not assign implementation
stages. The maintainer preparing the first release owns closure.
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.
## Outstanding prerequisites
## Preparation evidence
| Gap | Evidence required to close |
| Preparation item | Status and evidence |
| --- | --- |
| No Go module or declared support matrix | Established module path, supported Go versions and platform targets documented in the release procedure |
| No release runbook | `docs/release.md` exists with the prerequisites, commands, publication guards, and verification required by release policy |
| No complete release checker | A documented command exists and exercises every applicable automated candidate gate; manual gates and inapplicable checks are explicit |
| No consumer verification procedure | Runbook verifies retrieval of the exact released module version and a representative consumer build without local replacement directives |
| No verified secret/precedence/explanation contracts | Current contracts and passing bootstrap skip/partial-failure, secret isolation, CLI precedence, and safe historical-value regressions; representative consumer examples |
| No validated release candidate | Version-matched release note, current contracts/examples, and complete checks against the exact committed candidate |
| 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. |
No release can proceed while these prerequisites remain open. Close items only
on evidence from actual files and checks, not proposed commands. Record closure
evidence here while this roadmap remains active. Once all gaps are closed,
remove the bootstrap exception and retire this document in favor of the
operational runbook and normal release records.
## 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.

151
documentation_test.go Normal file
View File

@@ -0,0 +1,151 @@
package configkit
import (
"bufio"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"unicode"
)
var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`)
func TestLocalDocumentationLinks(t *testing.T) {
repository, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
var failures []string
err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if entry.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
content, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) {
target := strings.Trim(match[1], "<>")
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
continue
}
filePart, fragment, _ := strings.Cut(target, "#")
decoded, decodeErr := url.PathUnescape(filePart)
if decodeErr != nil {
failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target))
continue
}
linked := path
if decoded != "" {
linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded)))
}
_, statErr := os.Stat(linked)
outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator))
if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) {
continue // Recorded sibling-source links are optional in isolated clones.
}
if statErr != nil {
failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target))
continue
}
if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) {
failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(failures) != 0 {
t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n"))
}
}
func optionalSiblingSource(repository, linked string) bool {
relative, err := filepath.Rel(filepath.Dir(repository), linked)
if err != nil || relative == "." || filepath.IsAbs(relative) {
return false
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if len(parts) == 0 {
return false
}
switch strings.ToLower(parts[0]) {
case "narratio", "notarius", "weatherreporter", "seriatim":
return true
default:
return false
}
}
func markdownHasFragment(path, wanted string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
wanted, err = url.PathUnescape(wanted)
if err != nil {
return false
}
seen := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") {
continue
}
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
base := markdownFragment(heading)
count := seen[base]
seen[base]++
fragment := base
if count > 0 {
fragment = fmt.Sprintf("%s-%d", base, count)
}
if fragment == wanted {
return true
}
}
return false
}
func markdownFragment(heading string) string {
var result strings.Builder
previousSpace := false
for _, character := range strings.ToLower(heading) {
switch {
case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-':
result.WriteRune(character)
previousSpace = false
case unicode.IsSpace(character):
if result.Len() > 0 && !previousSpace {
result.WriteByte('-')
previousSpace = true
}
}
}
return strings.TrimRight(result.String(), "-")
}
func relativePath(root, path string) string {
relative, err := filepath.Rel(root, path)
if err != nil {
return path
}
return filepath.ToSlash(relative)
}

150
example_test.go Normal file
View File

@@ -0,0 +1,150 @@
package configkit_test
import (
"errors"
"fmt"
"os"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func ExampleLoad() {
type Config struct {
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
}
result, err := configkit.Load[Config](configkit.Bytes("example.yml", []byte("enabled: true\nworkers: 3\n")))
fmt.Println(result.Config, err)
// Output: {true 3} <nil>
}
func ExampleNoSource() {
type Config struct {
Workers int `yaml:"workers"`
}
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
})
fmt.Println(result.Config.Workers, len(result.Sources()), err)
// Output: 4 0 <nil>
}
func ExampleValidation_Error() {
type Config struct {
First string `yaml:"first"`
Second string `yaml:"second"`
}
_, err := configkit.Load(configkit.Bytes("duplicates.yml", []byte("first: same\nsecond: same\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Validate: func(configuration *Config, validation *configkit.Validation) {
if configuration.First == configuration.Second {
validation.Error(configkit.Keys("second"), "must differ from first", configkit.Keys("first"))
}
}},
})
var loadError *configkit.LoadError
errors.As(err, &loadError)
diagnostic := loadError.Diagnostics()[0]
fmt.Println(diagnostic.Code, diagnostic.Path, len(diagnostic.Related))
// Output: validate.error /second 1
}
func ExampleVersionRule() {
type Config struct {
Version string `yaml:"version"`
}
_, err := configkit.Load(configkit.Bytes("version.yml", []byte("version: old\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(version configkit.Version, validation *configkit.Validation) error {
if version.Text != "v1" {
validation.Error(configkit.Keys("version"), "unsupported version; migrate using the application guide")
}
return nil
},
}},
})
var loadError *configkit.LoadError
errors.As(err, &loadError)
fmt.Println(loadError.Diagnostics()[0].Message)
// Output: unsupported version; migrate using the application guide
}
func ExampleResolution() {
type Config struct {
Input int `yaml:"input"`
Derived int `yaml:"derived"`
}
var order []string
result, err := configkit.Load(configkit.Bytes("resolved.yml", []byte("input: 3\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
order = append(order, "resolve")
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived,
configuration.Input*2, "derived from input", configkit.Keys("input"))
return nil
},
Validate: func(configuration *Config, validation *configkit.Validation) {
order = append(order, "validate")
if configuration.Derived < configuration.Input {
validation.Error(configkit.Keys("derived"), "must not be less than input")
}
},
},
})
fmt.Println(strings.Join(order, " then "), result.Config.Derived, err)
// Output: resolve then validate 6 <nil>
}
func ExampleSecretEnv() {
const name = "CONFIGKIT_EXAMPLE_API_KEY"
previous, present := os.LookupEnv(name)
_ = os.Setenv(name, "disposable-example-value")
defer func() {
if present {
_ = os.Setenv(name, previous)
} else {
_ = os.Unsetenv(name)
}
}()
type Config struct {
APIKey configkit.Secret `yaml:"api_key"`
}
result, err := configkit.Load[Config](configkit.Bytes("secret.yml", []byte("api_key: "+name+"\n")))
fmt.Println(result.Config.APIKey.EnvName(), result.Config.APIKey.IsResolved(), err)
// Output: CONFIGKIT_EXAMPLE_API_KEY true <nil>
}
func ExampleOverride() {
type Config struct {
Workers int `yaml:"workers"`
}
workers, workersWasSet := 0, true // Supplied by the application's CLI parser.
var overrides []configkit.Override
if workersWasSet {
overrides = append(overrides, configkit.Override{
Path: configkit.Keys("workers"), Value: workers, Flag: "--workers",
})
}
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
Overrides: overrides,
})
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
// Output: 0 --workers <nil>
}
func ExampleResult_Explain() {
type Config struct {
Workers int `yaml:"workers"`
}
result, err := configkit.Load(configkit.Bytes("config.yml", []byte("workers: 8\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
})
explanation, ok := result.Explain(configkit.Keys("workers"))
fmt.Printf("%s -> %s known=%t error=%v\n",
strings.TrimSpace(explanation.History[0].After.YAML),
strings.TrimSpace(explanation.Effective.YAML), ok, err)
// Output: 4 -> 8 known=true error=<nil>
}

View File

@@ -0,0 +1,331 @@
// Package consumers contains small, maintained application shapes that exercise
// ConfigKit without making consumer domain policy part of the library API.
package consumers
import (
"math"
"strconv"
"strings"
"time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// Artifact is a Narratio-shaped generated output declaration.
type Artifact struct {
Output string `yaml:"output"`
Timeout time.Duration `yaml:"timeout"`
}
// NarratioConfig demonstrates application defaults, partial imports, and
// application-owned model-profile and artifact semantics.
type NarratioConfig struct {
PollInterval time.Duration `yaml:"poll_interval"`
Timeout time.Duration `yaml:"timeout"`
ModelProfile string `yaml:"model_profile"`
Artifacts map[string]Artifact `yaml:"artifacts"`
}
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
return LoadNarratioProfile(path, "")
}
// LoadNarratioProfile selects a ConfigKit configuration overlay independently
// from the application's ModelProfile field.
func LoadNarratioProfile(path, profile string) (*configkit.Result[NarratioConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
Imports: true, Profile: profile,
Definition: configkit.Definition[NarratioConfig]{
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
Resolve: resolveNarratio,
Validate: validateNarratio,
},
})
}
func resolveNarratio(configuration *NarratioConfig, resolution *configkit.Resolution) error {
configkit.Default(resolution, configkit.Keys("timeout"), &configuration.Timeout,
configuration.PollInterval/10, "derived from poll interval", configkit.Keys("poll_interval"))
for name, artifact := range configuration.Artifacts {
path := configkit.Keys("artifacts", name)
configkit.Default(resolution, path.Key("timeout"), &artifact.Timeout,
configuration.Timeout, "inherited pipeline timeout", configkit.Keys("timeout"))
configkit.Normalize(resolution, path.Key("output"), &artifact.Output,
strings.TrimSpace(artifact.Output), "trimmed artifact output")
configuration.Artifacts[name] = artifact
}
return nil
}
func validateNarratio(configuration *NarratioConfig, validation *configkit.Validation) {
if configuration.PollInterval <= 0 {
validation.Error(configkit.Keys("poll_interval"), "must be positive")
}
if configuration.Timeout <= 0 {
validation.Error(configkit.Keys("timeout"), "must be positive")
}
for name, artifact := range configuration.Artifacts {
path := configkit.Keys("artifacts", name)
if artifact.Output == "" {
validation.Error(path.Key("output"), "must be nonempty")
}
if artifact.Timeout <= 0 {
validation.Error(path.Key("timeout"), "must be positive")
}
}
}
// ObjectBinding is a Notarius-shaped canonical object binding. Options remain
// dynamic because the application catalog selects their schema at runtime.
type ObjectBinding struct {
Object string `yaml:"object"`
Options map[string]any `yaml:"options"`
}
// NotariusConfig demonstrates string versions and dynamic catalog options.
type NotariusConfig struct {
Version string `yaml:"version"`
Bindings []ObjectBinding `yaml:"bindings"`
}
// LoadNotarius loads a Notarius-shaped configuration.
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
Definition: configkit.Definition[NotariusConfig]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("v1")},
Validate: validateNotarius,
},
})
}
func requireVersion(expected string) func(configkit.Version, *configkit.Validation) error {
return func(version configkit.Version, validation *configkit.Validation) error {
if !version.Present || version.Null || version.Text != expected {
validation.Error(configkit.Keys("version"), "unsupported configuration version")
}
return nil
}
}
func validateNotarius(configuration *NotariusConfig, validation *configkit.Validation) {
for index, binding := range configuration.Bindings {
path := configkit.Keys("bindings").Index(index)
if binding.Object != "summary" && binding.Object != "registry" {
validation.Error(path.Key("object"), "unsupported object binding")
}
for key, value := range binding.Options {
optionPath := path.Key("options").Key(key)
switch key {
case "window_units":
units, ok := value.(int64)
if !ok || units < 1 {
validation.Error(optionPath, "must be a positive integer")
}
case "format":
if _, ok := value.(string); !ok {
validation.Error(optionPath, "must be a string")
}
default:
validation.Error(optionPath, "unsupported object option")
}
}
}
}
// NotariusWindow converts ConfigKit's portable dynamic integer only at the
// application runtime boundary.
func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
if binding < 0 || binding >= len(configuration.Bindings) {
return 0, false
}
value, ok := configuration.Bindings[binding].Options["window_units"].(int64)
if !ok || value < 1 || (strconv.IntSize == 32 && value > math.MaxInt32) {
return 0, false
}
return int(value), true
}
// ModuleOptions is a WeatherReporter-shaped union of known module option fields.
type ModuleOptions struct {
WindowUnits *int `yaml:"window_units"`
Sections []string `yaml:"sections"`
}
// ModuleSpec uses the canonical object form instead of a string shorthand.
type ModuleSpec struct {
Module string `yaml:"module"`
Options ModuleOptions `yaml:"options"`
}
// ReportConfig keeps report-specific lists application-owned.
type ReportConfig struct {
Modules []ModuleSpec `yaml:"modules"`
PathTemplates []string `yaml:"path_templates"`
}
// WeatherConfig demonstrates simple defaults, reports, module options, and an
// optional typed secret reference.
type WeatherConfig struct {
Units string `yaml:"units"`
Reports map[string]ReportConfig `yaml:"reports"`
Token *configkit.Secret `yaml:"token"`
}
// LoadWeather loads a WeatherReporter-shaped source and explicit CLI records.
func LoadWeather(source configkit.Source, overrides ...configkit.Override) (*configkit.Result[WeatherConfig], error) {
return configkit.Load(source, configkit.Options[WeatherConfig]{
Overrides: overrides,
Definition: weatherDefinition(func() WeatherConfig {
return WeatherConfig{Units: "metric"}
}),
})
}
// LoadConstructedWeather runs the same rules over a detached constructed value.
func LoadConstructedWeather(configuration WeatherConfig) (*configkit.Result[WeatherConfig], error) {
return configkit.Load(configkit.NoSource(), configkit.Options[WeatherConfig]{
Definition: weatherDefinition(func() WeatherConfig { return configuration }),
})
}
func weatherDefinition(defaults func() WeatherConfig) configkit.Definition[WeatherConfig] {
return configkit.Definition[WeatherConfig]{
Defaults: defaults,
Resolve: func(configuration *WeatherConfig, resolution *configkit.Resolution) error {
for reportName, report := range configuration.Reports {
var modules []ModuleSpec
if report.Modules != nil {
modules = append(make([]ModuleSpec, 0, len(report.Modules)), report.Modules...)
}
for index, module := range modules {
if module.Options.Sections != nil {
sections := append([]string(nil), module.Options.Sections...)
for position := range sections {
sections[position] = strings.TrimSpace(sections[position])
}
module.Options.Sections = sections
}
modules[index] = module
}
configkit.Normalize(resolution, configkit.Keys("reports", reportName, "modules"),
&report.Modules, modules, "normalized module options")
configuration.Reports[reportName] = report
}
return nil
},
Validate: validateWeather,
}
}
func validateWeather(configuration *WeatherConfig, validation *configkit.Validation) {
if configuration.Units != "metric" && configuration.Units != "us" {
validation.Error(configkit.Keys("units"), "must be metric or us")
}
for reportName, report := range configuration.Reports {
path := configkit.Keys("reports", reportName)
if report.Modules != nil && len(report.Modules) == 0 {
validation.Error(path.Key("modules"), "must contain at least one module")
}
if report.PathTemplates != nil && len(report.PathTemplates) == 0 {
validation.Error(path.Key("path_templates"), "must contain at least one template")
}
for index, module := range report.Modules {
modulePath := path.Key("modules").Index(index)
switch module.Module {
case "chunker":
if module.Options.WindowUnits == nil || *module.Options.WindowUnits < 1 {
validation.Error(modulePath.Key("options").Key("window_units"), "must be a positive integer")
}
if module.Options.Sections != nil {
validation.Error(modulePath.Key("options").Key("sections"), "not supported by this module")
}
case "discussion":
if module.Options.WindowUnits != nil {
validation.Error(modulePath.Key("options").Key("window_units"), "not supported by this module")
}
if len(module.Options.Sections) == 0 {
validation.Error(modulePath.Key("options").Key("sections"), "must contain at least one section")
}
default:
validation.Error(modulePath.Key("module"), "unsupported module")
}
}
}
}
// SpeakerRule and SpeakerRules preserve declaration order for application matching.
type SpeakerRule struct {
Prefix string `yaml:"prefix"`
Name string `yaml:"name"`
}
type SpeakerRules struct {
Rules []SpeakerRule `yaml:"rules"`
}
// AutocorrectRule and AutocorrectRules preserve replacement order.
type AutocorrectRule struct {
Find string `yaml:"find"`
Replace string `yaml:"replace"`
}
type AutocorrectRules struct {
Rules []AutocorrectRule `yaml:"rules"`
}
// LoadSpeakerRules loads one independent Seriatim-shaped rule file.
func LoadSpeakerRules(path string) (*configkit.Result[SpeakerRules], error) {
return configkit.Load(configkit.File(path), configkit.Options[SpeakerRules]{
Definition: configkit.Definition[SpeakerRules]{Validate: validateSpeakerRules},
})
}
// LoadAutocorrectRules loads another independent ordered rule file.
func LoadAutocorrectRules(path string) (*configkit.Result[AutocorrectRules], error) {
return configkit.Load(configkit.File(path), configkit.Options[AutocorrectRules]{
Definition: configkit.Definition[AutocorrectRules]{Validate: validateAutocorrectRules},
})
}
func validateSpeakerRules(configuration *SpeakerRules, validation *configkit.Validation) {
seen := make(map[string]int)
for index, rule := range configuration.Rules {
path := configkit.Keys("rules").Index(index)
if rule.Prefix == "" {
validation.Error(path.Key("prefix"), "must be nonempty")
}
if previous, ok := seen[rule.Prefix]; ok {
validation.Error(path.Key("prefix"), "prefix must be unique",
configkit.Keys("rules").Index(previous).Key("prefix"))
}
seen[rule.Prefix] = index
}
}
func validateAutocorrectRules(configuration *AutocorrectRules, validation *configkit.Validation) {
for index, rule := range configuration.Rules {
path := configkit.Keys("rules").Index(index)
if rule.Find == "" {
validation.Error(path.Key("find"), "must be nonempty")
}
}
}
// SpeakerName is application runtime matching, not configuration infrastructure.
func SpeakerName(rules SpeakerRules, input string) (string, bool) {
for _, rule := range rules.Rules {
if strings.HasPrefix(input, rule.Prefix) {
return rule.Name, true
}
}
return "", false
}
// ApplyAutocorrect is application runtime replacement in declared order.
func ApplyAutocorrect(rules AutocorrectRules, input string) string {
for _, rule := range rules.Rules {
input = strings.ReplaceAll(input, rule.Find, rule.Replace)
}
return input
}

View File

@@ -0,0 +1,252 @@
package consumers_test
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_narratio() {
result, err := consumers.LoadNarratio(fixture("narratio", "pipeline.yml"))
artifact := result.Config.Artifacts["transcript"]
fmt.Println(result.Config.ModelProfile, artifact.Output, result.Config.Timeout, artifact.Timeout, err)
// Output: concise transcript.txt 6s 6s <nil>
}
func Example_notarius() {
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
window, ok := consumers.NotariusWindow(result.Config, 0)
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
// Output: v1 summary 12 true <nil>
}
func Example_weatherReporter() {
defaults, defaultsErr := consumers.LoadWeather(configkit.NoSource())
result, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
discussion := result.Config.Reports["daily"].Modules[1]
fmt.Println(defaults.Config.Units, result.Config.Units, discussion.Options.Sections[0], defaultsErr, err)
// Output: metric us short_term <nil> <nil>
}
func Example_seriatim() {
speakers, speakerErr := consumers.LoadSpeakerRules(fixture("seriatim", "speakers.yml"))
autocorrect, autocorrectErr := consumers.LoadAutocorrectRules(fixture("seriatim", "autocorrect.yml"))
name, found := consumers.SpeakerName(speakers.Config, "Guest: teh event occured")
text := consumers.ApplyAutocorrect(autocorrect.Config, "teh event occured")
fmt.Println(name, found, text, speakerErr, autocorrectErr)
// Output: visitor true the event occurred <nil> <nil>
}
func Example_weatherStartup() {
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"
restoreBlocked := preserveEnvironment(blockedName)
restoreToken := preserveEnvironment(tokenName)
defer restoreBlocked()
defer restoreToken()
_ = os.Setenv(blockedName, "")
_ = os.Unsetenv(tokenName)
directory, _ := os.MkdirTemp("", "configkit-weather-example-")
defer os.RemoveAll(directory)
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600)
report, bootstrapErr := configkit.LoadSecretsDir(directory)
var reportOutput bytes.Buffer
_, reportWriteErr := report.WriteTo(&reportOutput)
result, loadErr := consumers.LoadWeather(configkit.Bytes("weather.yml", []byte("units: metric\ntoken: "+tokenName+"\n")),
configkit.Override{Path: configkit.Keys("units"), Value: "us", Flag: "--units"})
var explanation bytes.Buffer
explainErr := result.WriteExplanation(&explanation, configkit.Keys("units"))
blocked, loaded := secretStatus(report, blockedName), secretStatus(report, tokenName)
value, present := os.LookupEnv(tokenName)
fmt.Printf("blocked=%s loaded=%s report=%t reference=%s explained=%t unchanged=%t errors=%t\n",
blocked, loaded, reportOutput.Len() > 0, result.Config.Token.EnvName(),
strings.Contains(explanation.String(), "--units"), present && value == "disposable-example-credential",
bootstrapErr != nil || reportWriteErr != nil || loadErr != nil || explainErr != nil)
// Output: blocked=skipped_existing loaded=loaded report=true reference=CONFIGKIT_WEATHER_EXAMPLE_TOKEN explained=true unchanged=true errors=false
}
func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
narratio, err := consumers.LoadNarratio(fixture("narratio", "pipeline.yml"))
if err != nil || narratio.Config.Artifacts["transcript"].Output != "transcript.txt" ||
narratio.Config.ModelProfile != "concise" || len(narratio.Sources()) != 2 {
t.Fatalf("Narratio shape = %#v, %v", narratio, err)
}
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
t.Fatalf("artifact origin = %#v, %v", origin, ok)
}
profiled, err := consumers.LoadNarratioProfile(fixture("narratio", "pipeline.yml"), "testing")
if err != nil || profiled.Config.PollInterval.String() != "2m0s" ||
profiled.Config.ModelProfile != "concise" || len(profiled.Sources()) != 3 {
t.Fatalf("configuration/application profiles = %#v, %v", profiled, err)
}
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
window, converted := consumers.NotariusWindow(notarius.Config, 0)
if err != nil || !converted || window != 12 {
t.Fatalf("Notarius shape = %#v, %v, %v", notarius, converted, err)
}
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
}
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
t.Fatalf("WeatherReporter shape = %#v, %v", weather, err)
}
speakers, err := consumers.LoadSpeakerRules(fixture("seriatim", "speakers.yml"))
if err != nil {
t.Fatal(err)
}
name, ok := consumers.SpeakerName(speakers.Config, "Host: introduction")
if !ok || name != "narrator" || speakers.Config.Rules[0].Prefix != "Host:" {
t.Fatalf("ordered speaker rules = %#v, %q, %v", speakers.Config.Rules, name, ok)
}
}
func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
tests := []struct {
name string
load func() error
code configkit.Code
path string
}{
{
name: "integer schema version",
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
code: configkit.CodeVersionType, path: "/version",
},
{
name: "module shorthand",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "shorthand-module.yml")))
return err
},
code: configkit.CodeDecodeType, path: "/reports/daily/modules/0",
},
{
name: "explicit empty modules",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "empty-modules.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/modules",
},
{
name: "explicit empty path templates",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "empty-paths.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/path_templates",
},
{
name: "explicit null required option",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "null-window.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/modules/0/options/window_units",
},
{
name: "strict unknown rule field",
load: func() error {
_, err := consumers.LoadSpeakerRules(fixture("seriatim", "unknown-field.yml"))
return err
},
code: configkit.CodeDecodeUnknownField, path: "/rules/0/color",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var loadError *configkit.LoadError
if err := test.load(); !errors.As(err, &loadError) {
t.Fatalf("error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if strings.Contains(diagnostics[0].Message, "wrong-before-profile") {
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
}
})
}
}
func TestWeatherLoadedAndConstructedRulesAgreeWithoutMutation(t *testing.T) {
loaded, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
if err != nil {
t.Fatal(err)
}
reconstructed, err := consumers.LoadConstructedWeather(loaded.Config)
if err != nil || !reflect.DeepEqual(reconstructed.Config, loaded.Config) {
t.Fatalf("loaded/constructed outcomes differ: %#v, %v", reconstructed, err)
}
constructed := consumers.WeatherConfig{Units: loaded.Config.Units, Reports: map[string]consumers.ReportConfig{}}
constructed.Reports["daily"] = consumers.ReportConfig{
Modules: []consumers.ModuleSpec{{Module: "discussion", Options: consumers.ModuleOptions{Sections: []string{" short_term "}}}},
PathTemplates: []string{"daily/{date}.md"},
}
wantInput := constructed.Reports["daily"].Modules[0].Options.Sections[0]
result, err := consumers.LoadConstructedWeather(constructed)
if err != nil {
t.Fatal(err)
}
if got := constructed.Reports["daily"].Modules[0].Options.Sections[0]; got != wantInput {
t.Fatalf("constructed options mutated from %q to %q", wantInput, got)
}
if result.Config.Reports["daily"].Modules[0].Options.Sections[0] != "short_term" {
t.Fatalf("normalized result = %#v", result.Config.Reports["daily"])
}
empty := consumers.WeatherConfig{Units: "metric", Reports: map[string]consumers.ReportConfig{
"daily": {Modules: []consumers.ModuleSpec{}},
}}
_, err = consumers.LoadConstructedWeather(empty)
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || loadError.Diagnostics()[0].Code != configkit.CodeValidateError {
t.Fatalf("constructed empty modules error = %v", err)
}
if !reflect.DeepEqual(empty.Reports["daily"].Modules, []consumers.ModuleSpec{}) {
t.Fatal("constructed empty module input was mutated")
}
}
func fixture(parts ...string) string {
_, file, _, _ := runtime.Caller(0)
values := append([]string{filepath.Dir(file), "testdata"}, parts...)
return filepath.Join(values...)
}
func preserveEnvironment(name string) func() {
value, present := os.LookupEnv(name)
return func() {
if present {
_ = os.Setenv(name, value)
} else {
_ = os.Unsetenv(name)
}
}
}
func secretStatus(report *configkit.SecretsReport, name string) configkit.SecretFileStatus {
for _, entry := range report.Entries() {
if entry.Name == name {
return entry.Status
}
}
return "missing"
}

View File

@@ -0,0 +1,3 @@
artifacts:
transcript:
output: ' transcript.txt '

View File

@@ -0,0 +1,5 @@
_configkit:
imports:
- path: artifacts.yml
poll_interval: 1m
model_profile: concise

View File

@@ -0,0 +1 @@
poll_interval: 2m

View File

@@ -0,0 +1,6 @@
version: v1
bindings:
- object: summary
options:
window_units: 12
format: markdown

View File

@@ -0,0 +1,2 @@
version: 1
bindings: []

View File

@@ -0,0 +1,5 @@
rules:
- find: teh
replace: the
- find: occured
replace: occurred

View File

@@ -0,0 +1,5 @@
rules:
- prefix: 'Host:'
name: narrator
- prefix: 'Guest:'
name: visitor

View File

@@ -0,0 +1,4 @@
rules:
- prefix: 'Host:'
name: narrator
color: blue

View File

@@ -0,0 +1,11 @@
units: us
reports:
daily:
modules:
- module: chunker
options:
window_units: 12
- module: discussion
options:
sections: [' short_term ']
path_templates: ['daily/{date}.md']

View File

@@ -0,0 +1,3 @@
reports:
daily:
modules: []

View File

@@ -0,0 +1,3 @@
reports:
daily:
path_templates: []

View File

@@ -0,0 +1,6 @@
reports:
daily:
modules:
- module: chunker
options:
window_units: null

View File

@@ -0,0 +1,3 @@
reports:
daily:
modules: [chunker]

507
explain.go Normal file
View File

@@ -0,0 +1,507 @@
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")
)
// ValueSnapshot is a detached, deterministic YAML representation of one value.
// Present distinguishes absence or deletion from an explicit null value.
type ValueSnapshot struct {
Present bool
Redacted bool
YAML string
}
// ValueChange describes one explicit definition or audited domain operation.
type ValueChange struct {
Before ValueSnapshot
After ValueSnapshot
Origin Origin
Reasons []string
Dependencies []Path
}
// SecretReferenceInfo describes a successfully resolved effective reference.
// Name is the environment-variable name, never its value.
type SecretReferenceInfo struct {
Name string
}
// Explanation is a detached safe account of one effective or historical path.
type Explanation struct {
Path Path
Effective ValueSnapshot
Origin Origin
History []ValueChange
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() {
return Explanation{}, false
}
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
if info, ok := r.metadata.Lookup(path); ok {
explanation.Origin = cloneOrigin(info.Origin)
}
working := document.Clone(r.baseline)
if _, present := documentAtPath(working, path); present {
explanation.History = append(explanation.History, ValueChange{
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
})
}
for _, application := range r.applications {
operationPath, relevant := applicationOperationPath(application, path)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
applyHistoryApplication(&working, application)
if !relevant {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: valueSnapshot(working, path, r.secrets),
Origin: historyApplicationOrigin(application, operationPath),
})
}
working = document.Clone(r.input)
for _, snapshot := range r.resolution {
relevant := pathsIntersect(snapshot.change.Path, path)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
replaceHistoryPath(&working, snapshot.change.Path, snapshot.after)
if !relevant {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: valueSnapshot(working, path, r.secrets),
Origin: cloneOrigin(snapshot.change.Origin),
Reasons: append([]string(nil), snapshot.change.Reasons...),
Dependencies: clonePaths(snapshot.change.Dependencies),
})
}
if !explanation.Effective.Present && len(explanation.History) == 0 {
return Explanation{}, false
}
if name, ok := r.secretReferences[path.String()]; ok && !pathIsSecret(r.secrets, path) {
explanation.SecretReference = &SecretReferenceInfo{Name: name}
}
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')
}
}
}

328
explain_test.go Normal file
View File

@@ -0,0 +1,328 @@
package configkit
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type explanationConcurrency struct {
Transcription struct {
Concurrency int `yaml:"concurrency"`
} `yaml:"transcription"`
}
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
directory := t.TempDir()
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]{
Imports: true, Profile: "testing",
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
value := explanationConcurrency{}
value.Transcription.Concurrency = 4
return value
}},
})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Explain(Keys("transcription", "concurrency"))
if !ok {
t.Fatal("known path was not explained")
}
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
t.Fatalf("history values = %#v", got)
}
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerProfile ||
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
explanation.Origin.Locations[0].Line != 2 {
t.Fatalf("effective explanation = %#v", explanation)
}
}
type explanationConfig struct {
Concurrency int `yaml:"concurrency"`
Equal string `yaml:"equal"`
Limit int `yaml:"limit"`
Items []string `yaml:"items"`
Labels map[string]string `yaml:"labels"`
Dynamic any `yaml:"dynamic"`
Maybe *string `yaml:"maybe"`
Empty []string `yaml:"empty"`
Credential Secret `yaml:"credential"`
Classified Secret `yaml:"classified"`
DerivedA string `yaml:"derived_a"`
DerivedB string `yaml:"derived_b"`
}
func TestExplainReplaysCLIResolutionContainersAndSafeHistoricalValues(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
equal: same
limit: wrong-before-profile
items: [base, removed]
labels: {base: retained}
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
credential: APP_SECRET
classified: APP_SECRET
`)
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `concurrency: 1
equal: same
limit: 7
items: [profile]
labels: {profile: retained}
dynamic: selected
`)
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "APP_SECRET" {
t.Fatalf("secret lookup = %q", name)
}
return "RESOLVED_EXPLAIN_CANARY", true
}, Options[explanationConfig]{
Profile: "testing",
Overrides: []Override{
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
{Path: Keys("items"), Value: []any{"cli", "restored"}, Flag: "--items"},
{Path: Keys("labels"), Value: map[string]any{"cli": "retained"}, Flag: "--label"},
},
Definition: Definition[explanationConfig]{
Defaults: func() explanationConfig {
return explanationConfig{
Concurrency: 4, Equal: "same", Labels: map[string]string{"default": "retained"},
Maybe: nil, Empty: []string{}, Credential: SecretEnv("APP_SECRET"), Classified: SecretEnv("APP_SECRET"),
}
},
Secret: func(path Path) bool {
return path.String() == "/dynamic/retired" || path.String() == "/classified"
},
Resolve: func(configuration *explanationConfig, resolution *Resolution) error {
Normalize(resolution, Keys("concurrency"), &configuration.Concurrency, 3,
"normalized\nconcurrency", Keys("labels"))
Normalize(resolution, Keys("equal"), &configuration.Equal, "same", "record equal value")
Normalize(resolution, Keys("derived_a"), &configuration.DerivedA, "DERIVED_EXPLAIN_A",
"derived cycle a", Keys("derived_b"))
Normalize(resolution, Keys("derived_b"), &configuration.DerivedB, "DERIVED_EXPLAIN_B",
"derived cycle b", Keys("derived_a"), Keys("dynamic"))
return nil
},
},
})
if err != nil {
t.Fatal(err)
}
concurrency, ok := result.Explain(Keys("concurrency"))
if !ok {
t.Fatal("concurrency was not explained")
}
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
t.Fatalf("concurrency history = %#v", got)
}
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}) {
t.Fatalf("concurrency layers = %#v", got)
}
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
concurrency.History[4].Dependencies[0].String() != "/labels" {
t.Fatalf("resolution history = %#v", concurrency.History[4])
}
equal, _ := result.Explain(Keys("equal"))
if len(equal.History) != 5 {
t.Fatalf("equal writes were lost: %#v", equal.History)
}
for _, change := range equal.History[1:] {
if change.Before.YAML != "same\n" || change.After.YAML != "same\n" {
t.Fatalf("equal replacement = %#v", change)
}
}
limit, _ := result.Explain(Keys("limit"))
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-profile\n", "7\n"}) {
t.Fatalf("overridden invalid input was not retained: %#v", got)
}
removed, ok := result.Explain(Keys("dynamic", "retired"))
if !ok || removed.Effective.Present || len(removed.History) < 2 {
t.Fatalf("removed path = %#v, %v", removed, ok)
}
for _, change := range removed.History {
if strings.Contains(change.Before.YAML+change.After.YAML, "HISTORICAL_EXPLAIN_CANARY") {
t.Fatalf("historical secret escaped redaction: %#v", change)
}
}
if !removed.History[0].After.Redacted || removed.History[len(removed.History)-1].After.Present {
t.Fatalf("removed secret history = %#v", removed.History)
}
dynamic, _ := result.Explain(Keys("dynamic"))
if len(dynamic.History) < 3 || !dynamic.History[1].After.Redacted ||
strings.Contains(dynamic.History[1].After.YAML, "HISTORICAL_EXPLAIN_CANARY") {
t.Fatalf("container redaction history = %#v", dynamic.History)
}
items, _ := result.Explain(Keys("items"))
if len(items.History) != 4 || !strings.Contains(items.History[1].After.YAML, "removed") ||
strings.Contains(items.History[2].After.YAML, "removed") || !strings.Contains(items.History[3].After.YAML, "restored") {
t.Fatalf("atomic sequence history = %#v", items.History)
}
removedIndex, ok := result.Explain(Keys("items").Index(1))
if !ok || !removedIndex.History[1].Before.Present || removedIndex.History[1].After.Present ||
!removedIndex.History[2].After.Present {
t.Fatalf("sequence descendant history = %#v", removedIndex.History)
}
labels, _ := result.Explain(Keys("labels"))
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
!strings.Contains(labels.Effective.YAML, "profile: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
t.Fatalf("composite mapping history = %#v", labels)
}
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
t.Fatalf("composite effective origin = %#v", labels.Origin)
}
maybe, _ := result.Explain(Keys("maybe"))
empty, _ := result.Explain(Keys("empty"))
if !maybe.Effective.Present || maybe.Effective.YAML != "null\n" || !empty.Effective.Present || empty.Effective.YAML != "[]\n" {
t.Fatalf("null/empty snapshots = maybe %#v empty %#v", maybe.Effective, empty.Effective)
}
credential, _ := result.Explain(Keys("credential"))
if credential.SecretReference == nil || credential.SecretReference.Name != "APP_SECRET" || credential.Effective.YAML != "APP_SECRET\n" {
t.Fatalf("secret reference = %#v", credential)
}
classified, _ := result.Explain(Keys("classified"))
if classified.SecretReference != nil || !classified.Effective.Redacted ||
strings.Contains(classified.Effective.YAML, "APP_SECRET") {
t.Fatalf("classified secret reference = %#v", classified)
}
var derivedExplanations []Explanation
for _, path := range []Path{Keys("derived_a"), Keys("derived_b")} {
derived, _ := result.Explain(path)
if !derived.Effective.Redacted || strings.Contains(derived.Effective.YAML, "DERIVED_EXPLAIN") {
t.Fatalf("dependency closure at %s = %#v", path.String(), derived)
}
derivedExplanations = append(derivedExplanations, derived)
}
formatted := fmt.Sprintf("%v %#v %v", removed, credential, derivedExplanations)
encoded, marshalErr := json.Marshal(append([]Explanation{removed, credential, classified}, derivedExplanations...))
if marshalErr != nil || strings.Contains(formatted+string(encoded), "RESOLVED_EXPLAIN_CANARY") ||
strings.Contains(formatted+string(encoded), "HISTORICAL_EXPLAIN_CANARY") ||
strings.Contains(formatted+string(encoded), "DERIVED_EXPLAIN") {
t.Fatalf("public records disclosed a canary: %s %s", formatted, encoded)
}
concurrency.Path.segments[0].key = "mutated"
concurrency.History[4].Reasons[0] = "mutated"
concurrency.History[4].Dependencies[0].segments[0].key = "mutated"
result.Config.Concurrency = 99
again, _ := result.Explain(Keys("concurrency"))
if again.Path.String() != "/concurrency" || again.Effective.YAML != "3\n" ||
again.History[4].Reasons[0] != "normalized\nconcurrency" ||
again.History[4].Dependencies[0].String() != "/labels" {
t.Fatalf("returned explanation aliased internal history: %#v", again)
}
var first, second bytes.Buffer
if err := result.WriteExplanation(&first, Keys("concurrency")); err != nil {
t.Fatal(err)
}
if err := result.WriteExplanation(&second, Keys("concurrency")); err != nil || first.String() != second.String() {
t.Fatalf("nondeterministic rendering: %v\n%s\n%s", err, first.String(), second.String())
}
if strings.Contains(first.String(), "\nconcurrency\"") || !strings.Contains(first.String(), `"normalized\nconcurrency"`) {
t.Fatalf("reason controls were not escaped: %q", first.String())
}
unknownOutput := bytes.NewBufferString("unchanged")
if err := result.WriteExplanation(unknownOutput, Keys("unknown")); !errors.Is(err, errExplanationUnknownPath) || unknownOutput.String() != "unchanged" {
t.Fatalf("unknown writer = %q, %v", unknownOutput.String(), err)
}
if _, ok := result.Explain(Keys("items").Index(-1)); ok {
t.Fatal("malformed path was explained")
}
wantWriteError := errors.New("write failed")
if err := result.WriteExplanation(errorWriter{err: wantWriteError}, Keys("concurrency")); !errors.Is(err, wantWriteError) {
t.Fatalf("writer error = %v", err)
}
if err := result.WriteExplanation(shortWriter{}, Keys("concurrency")); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err)
}
var invalidInput bytes.Buffer
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
!strings.Contains(invalidInput.String(), "policy-validated input") ||
strings.Contains(invalidInput.String(), "wrong-before-profile\n source: layer=\"base\"\n before (canonical typed)") {
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
}
}
func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
const name = "CONFIGKIT_EXPLAIN_BOOTSTRAP_TEST"
previous, present := os.LookupEnv(name)
if err := os.Unsetenv(name); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if present {
_ = os.Setenv(name, previous)
} else {
_ = os.Unsetenv(name)
}
})
directory := t.TempDir()
writeSecretTestFile(t, directory, name, "BOOTSTRAP_EXPLAIN_CANARY\n")
report, err := LoadSecretsDir(directory)
if err != nil || len(report.Entries()) != 1 || report.Entries()[0].Status != SecretFileLoaded {
t.Fatalf("bootstrap = %#v, %v", report, err)
}
type config struct {
Credential Secret `yaml:"credential"`
}
result, err := Load[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
if err := result.WriteExplanation(&output, Keys("credential")); err != nil {
t.Fatal(err)
}
if !strings.Contains(output.String(), name) || strings.Contains(output.String(), "BOOTSTRAP_EXPLAIN_CANARY") {
t.Fatalf("explanation mixed bootstrap value into reference report: %s", output.String())
}
}
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
leaf := &document.Node{Kind: document.String, Text: "value"}
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
applications := []historyApplication{{layer: LayerBase, value: root}}
if !historyNodesWithin(2, root, root, root, applications, nil) {
t.Fatal("structurally shared history was counted more than once")
}
other := document.Clone(root)
if historyNodesWithin(3, root, other, root, applications, nil) {
t.Fatal("distinct retained nodes did not consume the budget")
}
}
func explanationValues(explanation Explanation) []string {
values := make([]string, len(explanation.History))
for index, change := range explanation.History {
values[index] = change.After.YAML
}
return values
}
func historyValueLayers(explanation Explanation) []Layer {
layers := make([]Layer, len(explanation.History))
for index, change := range explanation.History {
layers[index] = change.Origin.Layer
}
return layers
}

205
glob.go Normal file
View File

@@ -0,0 +1,205 @@
package configkit
import (
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
)
const globReadBatch = 128
type globCandidate struct {
absolute string
relative string
}
func (s *importTraversal) expandImportGlob(basePath string, declaration importDeclaration) ([]string, []Diagnostic) {
location := declaration.location
segments, valid := validGlobPattern(declaration.glob)
if !valid {
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid or uses recursive ** syntax", &location)}
}
candidates := []globCandidate{{absolute: basePath}}
for index, segment := range segments {
last := index == len(segments)-1
literal, patterned := globLiteralSegment(segment)
var next []globCandidate
for _, candidate := range candidates {
var names []string
if patterned {
matched, diagnostics := s.readGlobMatches(candidate.absolute, segment, location)
if len(diagnostics) != 0 {
return nil, diagnostics
}
names = matched
} else {
names = []string{literal}
}
for _, name := range names {
absolute := filepath.Join(candidate.absolute, filepath.FromSlash(name))
relative := path.Join(candidate.relative, name)
if !containedPath(s.rootPath, filepath.Clean(absolute)) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
}
canonical, err := canonicalExistingPath(absolute)
if err != nil {
if errors.Is(err, fs.ErrNotExist) && !patterned {
continue
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
}
if !containedPath(s.rootPath, canonical) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
}
if last {
next = append(next, globCandidate{absolute: canonical, relative: relative})
if len(s.sources)+len(next) > s.limits.sources {
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
}
continue
}
info, err := os.Stat(canonical)
if err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
}
if info.IsDir() {
next = append(next, globCandidate{absolute: canonical, relative: relative})
}
}
}
candidates = next
if len(candidates) == 0 {
return nil, nil
}
}
matches := make([]string, len(candidates))
for index, candidate := range candidates {
matches[index] = candidate.relative
}
slices.Sort(matches)
return matches, nil
}
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
canonical, err := canonicalExistingPath(directory)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
}
if !containedPath(s.rootPath, canonical) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
}
relative, err := filepath.Rel(s.rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
}
directoryFile, err := s.root.Open(relative)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be opened", location)}
}
var matches []string
for {
entries, readErr := directoryFile.ReadDir(globReadBatch)
for _, entry := range entries {
s.usedEntries++
if s.usedEntries > s.limits.entries {
_ = directoryFile.Close()
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
}
matched, matchErr := path.Match(pattern, entry.Name())
if matchErr != nil {
_ = directoryFile.Close()
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)}
}
if matched {
matches = append(matches, entry.Name())
}
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
_ = directoryFile.Close()
return nil, []Diagnostic{globFilesystemDiagnostic(readErr, "glob directory could not be read", location)}
}
}
if err := directoryFile.Close(); err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be closed", location)}
}
slices.Sort(matches)
return matches, nil
}
func validGlobPattern(pattern string) ([]string, bool) {
if !validImportPath(pattern) {
return nil, false
}
segments := strings.Split(pattern, "/")
for _, segment := range segments {
if segment == "" || hasRecursiveStars(segment) {
return nil, false
}
if _, err := path.Match(segment, ""); err != nil {
return nil, false
}
}
return segments, true
}
func hasRecursiveStars(segment string) bool {
inClass := false
for index := 0; index < len(segment); index++ {
switch segment[index] {
case '\\':
index++
case '[':
inClass = true
case ']':
inClass = false
case '*':
if !inClass && index+1 < len(segment) && segment[index+1] == '*' {
return true
}
}
}
return false
}
func globLiteralSegment(segment string) (string, bool) {
var literal strings.Builder
for index := 0; index < len(segment); index++ {
switch segment[index] {
case '*', '?', '[':
return "", true
case '\\':
index++
if index < len(segment) {
literal.WriteByte(segment[index])
}
default:
literal.WriteByte(segment[index])
}
}
return literal.String(), false
}
func globFilesystemDiagnostic(err error, message string, location Location) Diagnostic {
code := CodeImportRead
if errors.Is(err, fs.ErrPermission) {
code = CodeImportPermission
}
return importDiagnostic(code, message, &location)
}

273
glob_test.go Normal file
View File

@@ -0,0 +1,273 @@
package configkit
import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
directory := t.TempDir()
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:
imports: [{path: nested/child.yml}]
items: {alpha: alpha}
`)
childPath := writeImportFile(t, directory, "fragments/nested/child.yml", "items: {child: child}\n")
bPath := writeImportFile(t, directory, "fragments/b.yml", "items: {beta: beta}\n")
afterPath := writeImportFile(t, directory, "after.yml", "items: {after: after}\n")
var previous []SourceInfo
for run := 0; run < 2; run++ {
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil {
t.Fatal(err)
}
wantItems := map[string]string{"root": "root", "alpha": "alpha", "child": "child", "beta": "beta", "after": "after"}
if !reflect.DeepEqual(result.Config.Items, wantItems) {
t.Fatalf("items = %#v, want %#v", result.Config.Items, wantItems)
}
sources := result.Sources()
wantPaths := []string{
canonicalTestPath(t, mainPath), canonicalTestPath(t, aPath),
canonicalTestPath(t, childPath), canonicalTestPath(t, bPath),
canonicalTestPath(t, afterPath),
}
gotPaths := make([]string, len(sources))
for index, source := range sources {
gotPaths[index] = source.Path
}
if !reflect.DeepEqual(gotPaths, wantPaths) {
t.Fatalf("source paths = %#v, want %#v", gotPaths, wantPaths)
}
if run != 0 && !reflect.DeepEqual(sources, previous) {
t.Fatalf("repeated traversal changed sources: %#v != %#v", sources, previous)
}
previous = sources
}
}
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
imports:
- glob: 'conf.d/literal\*.yml'
- glob: 'conf.d/item-[ab].yml'
- glob: 'conf.d/question?.yml'
items: {root: root}
`)
writeImportFile(t, directory, "conf.d/literal*.yml", "items: {literal: literal}\n")
writeImportFile(t, directory, "conf.d/item-b.yml", "items: {b: b}\n")
writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n")
writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil {
t.Fatal(err)
}
if want := map[string]string{"root": "root", "literal": "literal", "a": "a", "b": "b", "question": "question"}; !reflect.DeepEqual(result.Config.Items, want) {
t.Fatalf("items = %#v, want %#v", result.Config.Items, want)
}
ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n")
_ = ignoredPath
plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n")
plain, err := Load[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(plain.Sources()) != 1 || plain.Config.Items["root"] != "only" {
t.Fatalf("implicit discovery result = %#v, %v", plain, err)
}
}
func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
tests := []struct {
name string
pattern string
code Code
}{
{name: "bad class", pattern: "empty/[", code: CodeImportInvalid},
{name: "recursive star segment", pattern: "empty/**/value.yml", code: CodeImportInvalid},
{name: "recursive adjacent stars", pattern: "empty/value**.yml", code: CodeImportInvalid},
{name: "trailing separator", pattern: "empty/", code: CodeImportInvalid},
{name: "absolute", pattern: "/tmp/*.yml", code: CodeImportInvalid},
{name: "URL", pattern: "https://example.invalid/*.yml", code: CodeImportInvalid},
{name: "missing parent escape", pattern: "../missing/*.yml", code: CodeImportOutsideRoot},
{name: "unmatched", pattern: "empty/*.yml", code: CodeImportMissing},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
t.Fatal(err)
}
mainPath := writeGlobRoot(t, directory, test.pattern, false)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
})
}
directory := t.TempDir()
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
t.Fatal(err)
}
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(result.Sources()) != 1 {
t.Fatalf("optional unmatched glob = %#v, %v", result, err)
}
}
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:
imports:
- glob: conf/*.yml
- glob: conf/value.*
`)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
})
t.Run("matched directory", func(t *testing.T) {
directory := t.TempDir()
if err := os.MkdirAll(filepath.Join(directory, "conf/matched.yml"), 0o755); err != nil {
t.Fatal(err)
}
mainPath := writeGlobRoot(t, directory, "conf/*.yml", true)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourceNotRegular)
})
t.Run("hard links stay distinct", func(t *testing.T) {
directory := t.TempDir()
one := writeImportFile(t, directory, "conf/one.yml", "items: {same: value}\n")
if err := os.Link(one, filepath.Join(directory, "conf/two.yml")); err != nil {
t.Skipf("hard links unavailable: %v", err)
}
mainPath := writeGlobRoot(t, directory, "conf/*.yml", false)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
})
}
func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("initial filesystem contract covers Linux and macOS")
}
t.Run("contained absolute target", func(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "target")
writeImportFile(t, target, "value.yml", "items: {linked: linked}\n")
if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Items["linked"] != "linked" {
t.Fatalf("contained directory symlink = %#v, %v", result, err)
}
})
t.Run("escaping target", func(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
writeImportFile(t, outside, "value.yml", "items: {escaped: escaped}\n")
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeGlobRoot(t, root, "linked/*.yml", true)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
})
}
func TestOptionalGlobDoesNotSuppressDirectoryPermissionFailure(t *testing.T) {
directory := t.TempDir()
locked := filepath.Join(directory, "locked")
if err := os.Mkdir(locked, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Chmod(locked, 0); err != nil {
t.Skipf("cannot create unreadable directory fixture: %v", err)
}
defer func() { _ = os.Chmod(locked, 0o700) }()
if opened, err := os.Open(locked); err == nil {
_ = opened.Close()
t.Skip("filesystem does not enforce the unreadable-directory fixture")
}
mainPath := writeGlobRoot(t, directory, "locked/*.yml", true)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportPermission)
}
func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
directory := t.TempDir()
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
writeImportFile(t, directory, "many/"+name, "ignored\n")
}
mainPath := writeGlobRoot(t, directory, "many/*.yml", true)
main := parseImportMain(t, mainPath)
limits := defaultImportLimits()
limits.entries = 2
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("entry-budget diagnostics = %#v", diagnostics)
}
for _, name := range []string{"a.yml", "b.yml"} {
writeImportFile(t, directory, "matches/"+name, "items: {"+name[:1]+": value}\n")
}
mainPath = writeGlobRoot(t, directory, "matches/*.yml", false)
main = parseImportMain(t, mainPath)
limits = defaultImportLimits()
limits.sources = 2
_, _, diagnostics = loadImportedDocuments(File(mainPath), nil, main, nil, limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("candidate-budget diagnostics = %#v", diagnostics)
}
}
func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
Profile: "default", Filesystem: &FilesystemContext{Root: directory},
})
if err != nil || result.Config.Items["root"] != "root" {
t.Fatalf("file filesystem context = %#v, %v", result, err)
}
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}}, CodeOptionsInvalid)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid)
input := Bytes("memory.yml", []byte("items: {root: memory}\n"))
result, err = Load[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}})
if err != nil || result.Config.Items["root"] != "memory" {
t.Fatalf("byte filesystem context = %#v, %v", result, err)
}
requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid)
defaultsOnly, err := Load[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"})
if err != nil || defaultsOnly == nil {
t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err)
}
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
selectedLink := filepath.Join(directory, "selected-link.yml")
if err := os.Symlink(mainPath, selectedLink); err == nil {
linked, err := Load[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}})
if err != nil || linked.Sources()[0].Path != canonicalTestPath(t, mainPath) {
t.Fatalf("selected symlink identity = %#v, %v", linked, err)
}
}
}
}
func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) string {
t.Helper()
optionalLine := ""
if optional {
optionalLine = "\n optional: true"
}
return writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module gitea.maximumdirect.net/eric/ConfigKit
go 1.25.0
require go.yaml.in/yaml/v4 v4.0.0-rc.6

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=

50
import_boundary_test.go Normal file
View File

@@ -0,0 +1,50 @@
package configkit
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestDependencyImportBoundary(t *testing.T) {
t.Parallel()
err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if path == ".git" {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, imported := range file.Imports {
importPath, err := strconv.Unquote(imported.Path.Value)
if err != nil {
return err
}
if strings.HasPrefix(importPath, "go.yaml.in/yaml/") && !strings.HasPrefix(filepath.ToSlash(path), "internal/yaml/") {
t.Errorf("%s imports private YAML dependency %s", path, importPath)
}
if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") {
t.Errorf("%s imports test-only dependency %s", path, importPath)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}

666
imports.go Normal file
View File

@@ -0,0 +1,666 @@
package configkit
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
const (
maxImportDepth = 64
maxImportSources = 1024
maxGlobEntries = 1_000_000
)
type importLimits struct {
depth int
sources int
bytes int64
nodes int
entries int
}
func defaultImportLimits() importLimits {
return importLimits{
depth: maxImportDepth, sources: maxImportSources,
bytes: maxTotalBytes, nodes: maxDocumentNodes, entries: maxGlobEntries,
}
}
type importDeclaration struct {
path string
glob string
optional bool
location Location
}
type importRoute struct {
canonical string
locations []Location
}
type importTraversal struct {
rootPath string
root *os.Root
limits importLimits
rule *VersionRule
documents []*document.Node
sources []SourceInfo
active map[string]int
visited map[string]importRoute
stack []importRoute
usedBytes int64
usedNodes int
usedEntries int
}
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
}
root, err := os.OpenRoot(rootPath)
if err != nil {
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
diagnostic.Primary = nil
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
}
mainInfo := cloneSourceInfo(*main.source)
if mainPath != "" {
mainInfo.Path = mainPath
}
state := importTraversal{
rootPath: rootPath, root: root, limits: limits, rule: rule,
documents: make([]*document.Node, 0, 1),
sources: []SourceInfo{mainInfo},
active: make(map[string]int), visited: make(map[string]importRoute),
usedBytes: main.bytes, usedNodes: countInputNodes(main.document),
}
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
_ = root.Close()
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
mainIdentity := mainPath
if mainIdentity == "" {
mainIdentity = "<selected-source>"
}
mainRoute := importRoute{canonical: mainIdentity}
state.active[mainIdentity] = 0
state.visited[mainIdentity] = mainRoute
state.stack = append(state.stack, mainRoute)
body, declarations, diagnostics := splitImportDirectives(main.document)
if len(diagnostics) == 0 {
state.documents = append(state.documents, body)
diagnostics = state.walkDeclarations(basePath, declarations, 0)
}
closeErr := root.Close()
if len(diagnostics) != 0 {
return nil, cloneSourceInfos(state.sources), diagnostics
}
if closeErr != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
}
composed, diagnostics := composeDocuments(state.documents...)
if composed != nil {
composed.inputBytes = state.usedBytes
composed.inputNodes = state.usedNodes
}
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)
return &value
}
if s.usedBytes > s.limits.bytes {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
return &value
}
if s.usedNodes > s.limits.nodes {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
return &value
}
return nil
}
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int) []Diagnostic {
for _, declaration := range declarations {
if declaration.glob != "" {
matches, diagnostics := s.expandImportGlob(basePath, declaration)
if len(diagnostics) != 0 {
return diagnostics
}
if len(matches) == 0 {
if declaration.optional {
continue
}
location := declaration.location
return []Diagnostic{importDiagnostic(CodeImportMissing, "required import glob matched no files", &location)}
}
for _, match := range matches {
matched := declaration
matched.path = match
matched.glob = ""
if diagnostics := s.visitPath(basePath, matched, depth+1); len(diagnostics) != 0 {
return diagnostics
}
}
continue
}
if diagnostics := s.visitPath(basePath, declaration, depth+1); len(diagnostics) != 0 {
return diagnostics
}
}
return nil
}
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, depth int) []Diagnostic {
location := declaration.location
if depth > s.limits.depth {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
}
if !validImportPath(declaration.path) {
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
}
candidate := filepath.Join(basePath, filepath.FromSlash(path.Clean(declaration.path)))
if !containedPath(s.rootPath, filepath.Clean(candidate)) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
}
canonical, err := canonicalExistingPath(candidate)
if err != nil {
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
return nil
}
code := CodeImportInvalid
message := "import target could not be canonicalized"
if errors.Is(err, fs.ErrNotExist) {
code, message = CodeImportMissing, "required import target does not exist"
} else if errors.Is(err, fs.ErrPermission) {
message = "import target cannot be accessed"
}
return []Diagnostic{importDiagnostic(code, message, &location)}
}
if !containedPath(s.rootPath, canonical) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
}
currentRoute := s.currentRoute(location)
if activeIndex, ok := s.active[canonical]; ok {
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
}
if previous, ok := s.visited[canonical]; ok {
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
}
if len(s.sources)+1 > s.limits.sources {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
}
relative, err := filepath.Rel(s.rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
}
sourceID := "source-" + strconv.Itoa(len(s.sources)+1)
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
s.sources = append(s.sources, info)
data, skipped, diagnostic := s.readImport(relative, sourceID, declaration.optional)
if diagnostic != nil {
return []Diagnostic{*diagnostic}
}
if skipped {
s.sources = s.sources[:len(s.sources)-1]
return nil
}
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
})
if len(issues) != 0 {
return importParseDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(root)
if s.usedNodes+nodeCount > s.limits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
}
s.usedNodes += nodeCount
body, declarations, diagnostics := splitImportDirectives(root)
if len(diagnostics) != 0 {
return diagnostics
}
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
versionLocation := publicLocation(versionNode.Location)
return []Diagnostic{{
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation,
}}
}
route := importRoute{canonical: canonical, locations: currentRoute.locations}
s.documents = append(s.documents, body)
s.active[canonical] = len(s.stack)
s.visited[canonical] = route
s.stack = append(s.stack, route)
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth)
delete(s.active, canonical)
s.stack = s.stack[:len(s.stack)-1]
return diagnostics
}
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
remaining := s.limits.bytes - s.usedBytes
limit := min(int64(maxSourceBytes), remaining)
if limit < 0 {
limit = 0
}
file, err := s.root.Open(relative)
if err != nil {
if optional && errors.Is(err, fs.ErrNotExist) {
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})
return nil, false, &value
}
s.usedBytes += int64(len(data))
return data, false, nil
}
func (s *importTraversal) currentRoute(location Location) importRoute {
locations := make([]Location, 0, len(s.stack)+1)
for _, route := range s.stack {
if len(route.locations) != 0 {
locations = append(locations, route.locations[len(route.locations)-1])
}
}
locations = append(locations, location)
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
}
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))
for _, route := range cycle {
paths = append(paths, route.canonical)
if len(route.locations) != 0 {
related = append(related, route.locations[len(route.locations)-1])
}
}
paths = append(paths, target)
return Diagnostic{
Code: CodeImportCycle, Severity: SeverityError,
Message: "import cycle detected: " + strings.Join(paths, " -> "),
Primary: &location, Related: related,
}
}
func importRepeatedDiagnostic(location Location, previous, current importRoute) Diagnostic {
related := append([]Location(nil), previous.locations...)
if len(current.locations) > 1 {
related = append(related, current.locations[:len(current.locations)-1]...)
}
return Diagnostic{
Code: CodeImportRepeated, Severity: SeverityError,
Message: "configuration source is included by more than one import route",
Primary: &location, Related: related,
}
}

455
imports_test.go Normal file
View File

@@ -0,0 +1,455 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"testing"
)
type importTestConfig struct {
Settings struct {
Root string `yaml:"root"`
First string `yaml:"first"`
Nested string `yaml:"nested"`
Second string `yaml:"second"`
} `yaml:"settings"`
Version string `yaml:"version"`
Items map[string]string `yaml:"items"`
}
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
imports:
- path: first.yml
- path: second.yml
settings:
root: root
`)
firstPath := writeImportFile(t, directory, "first.yml", `_configkit:
imports:
- path: nested/third.yml
settings:
first: first
`)
thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\n")
secondPath := writeImportFile(t, directory, "second.yml", "settings:\n second: second\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil {
t.Fatal(err)
}
if got := result.Config.Settings; got.Root != "root" || got.First != "first" || got.Nested != "nested" || got.Second != "second" {
t.Fatalf("composed settings = %#v", got)
}
sources := result.Sources()
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, firstPath), canonicalTestPath(t, thirdPath), canonicalTestPath(t, secondPath)}
gotPaths := make([]string, len(sources))
for index, source := range sources {
gotPaths[index] = source.Path
if source.ID != "source-"+strconv.Itoa(index+1) {
t.Errorf("source %d ID = %q", index, source.ID)
}
if index > 0 && source.ImportedFrom == nil {
t.Errorf("source %d has no import location", index)
}
}
if !reflect.DeepEqual(gotPaths, wantPaths) {
t.Fatalf("source paths = %#v, want depth-first %#v", gotPaths, wantPaths)
}
info, ok := result.Metadata().Lookup(Keys("settings"))
if !ok || !info.Explicit || len(info.Origin.Locations) != 4 {
t.Fatalf("settings metadata = %#v, %v", info, ok)
}
}
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
t.Run("all duplicate claimants", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
imports:
- path: one.yml
- path: two.yml
- path: three.yml
`)
for index, name := range []string{"one.yml", "two.yml", "three.yml"} {
writeImportFile(t, directory, name, "settings:\n root: "+string(rune('a'+index))+"\n")
}
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != "/settings/root" || diagnostic.Primary == nil || len(diagnostic.Related) != 2 {
t.Fatalf("conflict diagnostic = %#v", diagnostic)
}
})
versionRule := &VersionRule{Path: Keys("settings", "version"), Check: func(Version, *Validation) error { return nil }}
for _, test := range []struct {
name string
fragment string
}{
{name: "exact", fragment: "settings:\n version: v2\n"},
{name: "ancestor scalar", fragment: "settings: blocked\n"},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: fragment.yml\n")
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]
if diagnostic.Path.String() != "/settings/version" || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
t.Fatalf("version placement diagnostic = %#v", diagnostic)
}
})
}
}
func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
tests := []struct {
name string
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},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", test.body)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
})
}
}
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
t.Run("optional missing", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
imports:
- path: missing.yml
optional: true
settings: {root: retained}
`)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Settings.Root != "retained" || len(result.Sources()) != 1 {
t.Fatalf("optional result = %#v, %v", result, err)
}
})
tests := []struct {
name string
path string
setup func(*testing.T, string)
code Code
}{
{name: "required missing", path: "missing.yml", code: CodeImportMissing},
{name: "parent traversal", path: "../outside.yml", setup: func(t *testing.T, directory string) {
writeImportFile(t, filepath.Dir(directory), "outside.yml", "settings: {root: outside}\n")
}, code: CodeImportOutsideRoot},
{name: "directory", path: "directory", setup: func(t *testing.T, directory string) {
if err := os.Mkdir(filepath.Join(directory, "directory"), 0o755); err != nil {
t.Fatal(err)
}
}, code: CodeSourceNotRegular},
{name: "absolute", path: "/tmp/configkit.yml", code: CodeImportInvalid},
{name: "network URL", path: "https://example.invalid/config.yml", code: CodeImportInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
if test.setup != nil {
test.setup(t, directory)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: \""+test.path+"\"\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
})
}
}
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")
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") {
t.Fatalf("cycle diagnostic = %#v", diagnostic)
}
})
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")
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Primary == nil || len(diagnostic.Related) < 3 {
t.Fatalf("repeated-route diagnostic = %#v", diagnostic)
}
})
t.Run("symlink aliases", func(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("initial filesystem contract covers Linux and macOS")
}
directory := t.TempDir()
writeImportFile(t, directory, "target.yml", "settings: {root: target}\n")
if err := os.Symlink("target.yml", filepath.Join(directory, "first-link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
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")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
})
}
func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("initial filesystem contract covers Linux and macOS")
}
t.Run("contained absolute symlink", func(t *testing.T) {
directory := t.TempDir()
target := writeImportFile(t, directory, "target.yml", "settings: {first: linked}\n")
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")
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)
}
})
t.Run("symlink escape", func(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
target := writeImportFile(t, outside, "outside.yml", "settings: {root: outside}\n")
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")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
})
t.Run("hard links are path identities", func(t *testing.T) {
directory := t.TempDir()
one := writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
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")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
})
t.Run("canonicalization failure", func(t *testing.T) {
directory := t.TempDir()
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")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
})
}
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")
context := &FilesystemContext{BaseDir: directory, Root: directory}
for _, test := range []struct {
name string
source func() Source
}{
{name: "bytes URI label", source: func() Source { return Bytes("s3://bucket/main.yml", input) }},
{name: "reader URI label", source: func() Source { return Reader("https://example.invalid/main.yml", strings.NewReader(string(input))) }},
} {
t.Run(test.name, func(t *testing.T) {
result, err := Load[importTestConfig](test.source(), Options[importTestConfig]{Imports: true, Filesystem: context})
if err != nil || result.Config.Settings.First != "imported" {
t.Fatalf("explicit filesystem result = %#v, %v", result, err)
}
})
}
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true}, CodeOptionsInvalid)
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: "relative", Root: directory}}, CodeOptionsInvalid)
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: directory, Root: t.TempDir()}}, CodeImportOutsideRoot)
}
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
root := t.TempDir()
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},
})
if err != nil || result.Config.Settings.Root != "shared" {
t.Fatalf("explicit-root result = %#v, %v", result, err)
}
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: filepath.Dir(mainPath), Root: root}}, CodeOptionsInvalid)
}
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")
writeImportFile(t, directory, "broken.yml", "settings: [\n")
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
diagnostics := loadError.Diagnostics()
if diagnostics[0].Code != CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
t.Fatalf("parse failure = diagnostics %#v, sources %#v", diagnostics, loadError.Sources())
}
})
t.Run("decode", func(t *testing.T) {
directory := t.TempDir()
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]
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
t.Fatalf("decode diagnostic = %#v", diagnostic)
}
})
}
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
directory := t.TempDir()
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")
if err := os.Chmod(locked, 0); err != nil {
t.Skipf("cannot create unreadable fixture: %v", err)
}
defer func() { _ = os.Chmod(locked, 0o600) }()
if file, err := os.Open(locked); err == nil {
_ = 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")
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")
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
main := parseImportMain(t, mainPath)
for _, test := range []struct {
name string
limits importLimits
}{
{name: "source count", limits: importLimits{depth: 8, sources: 2, bytes: maxTotalBytes, nodes: maxDocumentNodes}},
{name: "aggregate bytes", limits: importLimits{depth: 8, sources: 8, bytes: main.bytes + 1, nodes: maxDocumentNodes}},
{name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}},
} {
t.Run(test.name, func(t *testing.T) {
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, test.limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("budget diagnostics = %#v", diagnostics)
}
})
}
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})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("depth diagnostics = %#v", diagnostics)
}
}
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
directory := t.TempDir()
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)
data, err := os.ReadFile(importedPath)
if err != nil {
t.Fatal(err)
}
limits := importLimits{
depth: 1,
sources: 2,
bytes: main.bytes + int64(len(data)),
nodes: countInputNodes(main.document) + countInputNodes(imported.document),
}
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics)
}
}
func writeImportFile(t *testing.T, root, relative, contents string) string {
t.Helper()
filename := filepath.Join(root, filepath.FromSlash(relative))
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return filename
}
func canonicalTestPath(t *testing.T, filename string) string {
t.Helper()
canonical, err := canonicalExistingPath(filename)
if err != nil {
t.Fatal(err)
}
return canonical
}
func requireLoadCode(t *testing.T, source Source, options Options[importTestConfig], code Code) *LoadError {
t.Helper()
loadError := requireAnyLoadError(t, source, options)
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v, want %s", diagnostics, code)
}
return loadError
}
func requireAnyLoadError(t *testing.T, source Source, options Options[importTestConfig]) *LoadError {
t.Helper()
result, err := Load[importTestConfig](source, options)
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
return loadError
}
func parseImportMain(t *testing.T, filename string) parsedSource {
t.Helper()
parsed, diagnostics := parseSource(File(filename), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics)
}
return parsed
}

116
independence_test.go Normal file
View File

@@ -0,0 +1,116 @@
package configkit
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"testing"
)
type independentText string
func (value independentText) MarshalText() ([]byte, error) {
return []byte(value), nil
}
func (value *independentText) UnmarshalText(text []byte) error {
*value = independentText(text)
return nil
}
type independentConfig struct {
Labels map[string]string `yaml:"labels"`
Items []string `yaml:"items"`
Custom independentText `yaml:"custom"`
}
func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
definition := Definition[independentConfig]{
Defaults: func() independentConfig {
return independentConfig{
Labels: map[string]string{"default": "kept"},
Items: []string{"default"},
Custom: "default",
}
},
}
const count = 32
results := make([]*Result[independentConfig], count)
errorsByIndex := make([]error, count)
var group sync.WaitGroup
for index := range count {
group.Add(1)
go func(index int) {
defer group.Done()
input := fmt.Sprintf("custom: value-%d\n", index)
results[index], errorsByIndex[index] = Load(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
}(index)
}
group.Wait()
for index, err := range errorsByIndex {
if err != nil {
t.Fatalf("Load(%d) failed: %v", index, err)
}
}
results[0].Config.Labels["default"] = "mutated"
results[0].Config.Items[0] = "mutated"
results[0].Config.Custom = "mutated"
for index := 1; index < count; index++ {
if results[index].Config.Labels["default"] != "kept" || results[index].Config.Items[0] != "default" || results[index].Config.Custom != independentText(fmt.Sprintf("value-%d", index)) {
t.Fatalf("result %d shares mutable state: %#v", index, results[index].Config)
}
}
var effective bytes.Buffer
if err := results[0].WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), "mutated") || !strings.Contains(effective.String(), "default: kept") || !strings.Contains(effective.String(), "value-0") {
t.Fatalf("frozen effective snapshot = %q", effective.String())
}
}
func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
source := Reader("shared", strings.NewReader("value: one\n"))
copied := source
type config struct {
Value string `yaml:"value"`
}
start := make(chan struct{})
errorsFound := make(chan error, 2)
var group sync.WaitGroup
for _, candidate := range []Source{source, copied} {
group.Add(1)
go func(candidate Source) {
defer group.Done()
<-start
_, err := Load[config](candidate)
errorsFound <- err
}(candidate)
}
close(start)
group.Wait()
close(errorsFound)
successes, used := 0, 0
for err := range errorsFound {
if err == nil {
successes++
continue
}
var loadError *LoadError
if errors.As(err, &loadError) {
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 1 && diagnostics[0].Code == CodeSourceReaderUsed {
used++
}
}
}
if successes != 1 || used != 1 {
t.Fatalf("successes=%d reader-used=%d", successes, used)
}
}

View File

@@ -0,0 +1,62 @@
// Package document defines ConfigKit's parser-independent configuration tree.
package document
// Kind identifies a configuration node category.
type Kind uint8
const (
Mapping Kind = iota + 1
Sequence
String
Boolean
Number
Null
)
// Location identifies a source coordinate. Root depth and unavailable coordinates
// are represented independently by callers; line and column are one-based when set.
type Location struct {
SourceID string
Line int
Column int
}
// Entry is an ordered mapping entry with separate key and value locations.
type Entry struct {
Key string
KeyLocation Location
ValueLocation Location
Value *Node
}
// 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
}
// Clone returns a detached copy of a node tree.
func Clone(node *Node) *Node {
if node == nil {
return nil
}
cloned := *node
if node.Mapping != nil {
cloned.Mapping = make([]Entry, len(node.Mapping))
for i, entry := range node.Mapping {
cloned.Mapping[i] = entry
cloned.Mapping[i].Value = Clone(entry.Value)
}
}
if node.Sequence != nil {
cloned.Sequence = make([]*Node, len(node.Sequence))
for i, child := range node.Sequence {
cloned.Sequence[i] = Clone(child)
}
}
return &cloned
}

View File

@@ -0,0 +1,61 @@
package yaml
import (
"bytes"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func FuzzParseDocument(f *testing.F) {
for _, seed := range []string{
"{}\n",
"service:\n enabled: true\n ports: [8080, 8081]\n",
"values: {empty: '', nothing: null, number: -1.5e2}\n",
"duplicate: one\nduplicate: two\n",
"alias: &value one\nother: *value\n",
} {
f.Add([]byte(seed))
}
limits := Limits{Depth: 64, Nodes: 4096, ParserDepth: 128}
f.Fuzz(func(t *testing.T, input []byte) {
if len(input) > 64<<10 {
return
}
parsed, issues := Parse(bytes.NewReader(input), "fuzz-input", limits)
if len(issues) != 0 {
return
}
var output bytes.Buffer
if err := Write(&output, parsed); err != nil {
t.Fatalf("Write() failed for accepted document: %v", err)
}
reparsed, issues := Parse(bytes.NewReader(output.Bytes()), "round-trip", limits)
if len(issues) != 0 {
t.Fatalf("rendered accepted document was rejected: %#v", issues)
}
clearLocations(parsed)
clearLocations(reparsed)
if !reflect.DeepEqual(parsed, reparsed) {
t.Fatalf("document changed across parse/write/parse: before=%#v after=%#v", parsed, reparsed)
}
})
}
func clearLocations(node *document.Node) {
if node == nil {
return
}
node.Location = document.Location{}
for index := range node.Mapping {
node.Mapping[index].KeyLocation = document.Location{}
node.Mapping[index].ValueLocation = document.Location{}
clearLocations(node.Mapping[index].Value)
}
for _, child := range node.Sequence {
clearLocations(child)
}
}

274
internal/yaml/parse.go Normal file
View File

@@ -0,0 +1,274 @@
// Package yaml adapts the private upstream parser to ConfigKit's document model.
package yaml
import (
"errors"
"io"
"regexp"
"strings"
"go.yaml.in/yaml/v4"
"go.yaml.in/yaml/v4/plugin/limit"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const (
codeInvalid = "yaml.invalid"
codeEmpty = "yaml.empty"
codeMultipleDocuments = "yaml.multiple_documents"
codeRootType = "yaml.root_type"
codeDuplicateKey = "yaml.duplicate_key"
codeTag = "yaml.tag"
codeAnchor = "yaml.anchor"
codeAlias = "yaml.alias"
codeMergeKey = "yaml.merge_key"
codeScalar = "yaml.scalar"
codeDepthLimit = "yaml.depth_limit"
codeNodeLimit = "yaml.node_limit"
)
var (
jsonNumberPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
decimalLikePattern = regexp.MustCompile(`^[+-]?[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9_]*)?$`)
prefixedNumberPattern = regexp.MustCompile(`^[+-]?0[xXoO][0-9A-Fa-f_]+$`)
leadingDecimalPattern = regexp.MustCompile(`^[+-]?\.[0-9_]+$`)
nonFiniteNumberPattern = regexp.MustCompile(`(?i)^[+-]?\.(inf|nan)$`)
errParserDepth = errors.New("configkit yaml parser depth limit")
)
// Limits controls private parser and translated-tree resource bounds.
type Limits struct {
Depth int
Nodes int
ParserDepth int
}
// Issue is a parser-independent, value-free YAML diagnostic.
type Issue struct {
Code string
Message string
Line int
Column int
Related []document.Location
}
// Parse loads exactly one policy-valid mapping document.
func Parse(input io.Reader, sourceID string, limits Limits) (*document.Node, []Issue) {
parserDepth := limits.ParserDepth
if parserDepth <= 0 {
parserDepth = limits.Depth + 2
}
depthExceeded := false
plugin := limit.New(limit.DepthFunc(func(depth int, _ *limit.DepthContext) error {
if depth > parserDepth {
depthExceeded = true
return errParserDepth
}
return nil
}))
loader, err := yaml.NewLoader(input, yaml.WithPlugin(plugin))
if err != nil {
return nil, []Issue{{Code: codeInvalid, Message: "YAML parser could not be initialized"}}
}
var parsed yaml.Node
if err := loader.Load(&parsed); err != nil {
if errors.Is(err, io.EOF) {
return nil, []Issue{{Code: codeEmpty, Message: "configuration input is empty"}}
}
return nil, []Issue{parserIssue(err, depthExceeded)}
}
var trailing yaml.Node
if err := loader.Load(&trailing); err == nil {
return nil, []Issue{{Code: codeMultipleDocuments, Message: "configuration input must contain exactly one document", Line: trailing.Line, Column: trailing.Column}}
} else if !errors.Is(err, io.EOF) {
return nil, []Issue{parserIssue(err, depthExceeded)}
}
root := documentRoot(&parsed)
if root == nil || (root.Kind == yaml.ScalarNode && root.Value == "" && root.ShortTag() == "!!null") {
return nil, []Issue{{Code: codeEmpty, Message: "configuration document is empty", Line: parsed.Line, Column: parsed.Column}}
}
if root.Kind != yaml.MappingNode {
return nil, []Issue{{Code: codeRootType, Message: "configuration document root must be a mapping", Line: root.Line, Column: root.Column}}
}
state := translationState{sourceID: sourceID, limits: limits}
translated, issue := state.translate(root, 0)
if issue != nil {
return nil, []Issue{*issue}
}
return translated, nil
}
type translationState struct {
sourceID string
limits Limits
nodes int
}
func (s *translationState) translate(node *yaml.Node, depth int) (*document.Node, *Issue) {
if depth > s.limits.Depth {
return nil, nodeIssue(codeDepthLimit, "configuration exceeds the nesting depth limit", node)
}
if node.Style&yaml.TaggedStyle != 0 {
return nil, nodeIssue(codeTag, "explicit YAML tags are not supported", node)
}
if node.Anchor != "" {
return nil, nodeIssue(codeAnchor, "YAML anchors are not supported", node)
}
if node.Kind == yaml.AliasNode || node.Alias != nil {
return nil, nodeIssue(codeAlias, "YAML aliases are not supported", node)
}
if issue := s.chargeNode(node); issue != nil {
return nil, issue
}
location := document.Location{SourceID: s.sourceID, Line: node.Line, Column: node.Column}
switch node.Kind {
case yaml.MappingNode:
if len(node.Content)%2 != 0 {
return nil, nodeIssue(codeInvalid, "mapping structure is invalid", node)
}
result := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(node.Content)/2)}
seen := make(map[string]document.Location, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
keyNode, valueNode := node.Content[i], node.Content[i+1]
if issue := s.chargeNode(keyNode); issue != nil {
return nil, issue
}
key, issue := mappingKey(keyNode)
if issue != nil {
return nil, issue
}
if key == "<<" {
return nil, nodeIssue(codeMergeKey, "YAML merge keys are not supported", keyNode)
}
keyLocation := document.Location{SourceID: s.sourceID, Line: keyNode.Line, Column: keyNode.Column}
if previous, ok := seen[key]; ok {
issue := nodeIssue(codeDuplicateKey, "mapping key is defined more than once", keyNode)
issue.Related = []document.Location{previous}
return nil, issue
}
seen[key] = keyLocation
value, issue := s.translate(valueNode, depth+1)
if issue != nil {
return nil, issue
}
result.Mapping = append(result.Mapping, document.Entry{
Key: key,
KeyLocation: keyLocation,
ValueLocation: value.Location,
Value: value,
})
}
return result, nil
case yaml.SequenceNode:
result := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, 0, len(node.Content))}
for _, child := range node.Content {
translated, issue := s.translate(child, depth+1)
if issue != nil {
return nil, issue
}
result.Sequence = append(result.Sequence, translated)
}
return result, nil
case yaml.ScalarNode:
kind, text, issue := scalar(node)
if issue != nil {
return nil, issue
}
return &document.Node{Kind: kind, Location: location, Text: text}, nil
default:
return nil, nodeIssue(codeInvalid, "YAML node kind is not supported", node)
}
}
func (s *translationState) chargeNode(node *yaml.Node) *Issue {
s.nodes++
if s.limits.Nodes >= 0 && s.nodes > s.limits.Nodes {
return nodeIssue(codeNodeLimit, "configuration exceeds the node limit", node)
}
return nil
}
func mappingKey(node *yaml.Node) (string, *Issue) {
if node.Kind == yaml.AliasNode || node.Alias != nil {
return "", nodeIssue(codeAlias, "YAML aliases are not supported", node)
}
if node.Kind != yaml.ScalarNode {
return "", nodeIssue(codeScalar, "mapping keys must be strings", node)
}
if node.Style&yaml.TaggedStyle != 0 {
return "", nodeIssue(codeTag, "explicit YAML tags are not supported", node)
}
if node.Anchor != "" {
return "", nodeIssue(codeAnchor, "YAML anchors are not supported", node)
}
kind, text, issue := scalar(node)
if issue != nil {
return "", issue
}
if kind != document.String {
return "", nodeIssue(codeScalar, "mapping keys must be strings", node)
}
return text, nil
}
func scalar(node *yaml.Node) (document.Kind, string, *Issue) {
if node.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 {
return document.String, node.Value, nil
}
value := node.Value
switch value {
case "":
if node.ShortTag() == "!!null" {
return document.Null, "", nil
}
case "null", "~":
return document.Null, "", nil
case "true", "false":
return document.Boolean, value, nil
}
lower := strings.ToLower(value)
if lower == "true" || lower == "false" || lower == "null" || lower == "yes" || lower == "no" || lower == "on" || lower == "off" {
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported reserved form", node)
}
if jsonNumberPattern.MatchString(value) {
return document.Number, value, nil
}
if decimalLikePattern.MatchString(value) || prefixedNumberPattern.MatchString(value) ||
leadingDecimalPattern.MatchString(value) || nonFiniteNumberPattern.MatchString(value) {
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported numeric form", node)
}
if tag := node.ShortTag(); tag == "!!int" || tag == "!!float" {
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported numeric form", node)
}
return document.String, value, nil
}
func documentRoot(node *yaml.Node) *yaml.Node {
if node.Kind == yaml.DocumentNode && len(node.Content) == 1 {
return node.Content[0]
}
return node
}
func parserIssue(err error, depthExceeded bool) Issue {
code := codeInvalid
if depthExceeded || errors.Is(err, errParserDepth) {
code = codeDepthLimit
}
issue := Issue{Code: code, Message: "configuration contains invalid YAML"}
var loadError *yaml.LoadError
if errors.As(err, &loadError) {
issue.Line = loadError.Mark.Line
issue.Column = loadError.Mark.Column
}
return issue
}
func nodeIssue(code, message string, node *yaml.Node) *Issue {
return &Issue{Code: code, Message: message, Line: node.Line, Column: node.Column}
}

200
internal/yaml/parse_test.go Normal file
View File

@@ -0,0 +1,200 @@
package yaml
import (
"io"
"strings"
"testing"
"go.yaml.in/yaml/v4"
"go.yaml.in/yaml/v4/plugin/limit"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestParsePreservesDocumentValuesAndLocations(t *testing.T) {
t.Parallel()
input := "# comment\nwide: 9223372036854775808\nwhen: 2026-09-06\nquoted: \"yes\"\nflow: [true, null, ~, false]\nempty:\n"
root, issues := Parse(strings.NewReader(input), "source-1", generousLimits())
if len(issues) != 0 {
t.Fatalf("Parse() issues = %#v", issues)
}
if root.Kind != document.Mapping || len(root.Mapping) != 5 {
t.Fatalf("root = %#v", root)
}
if root.Mapping[0].Value.Kind != document.Number || root.Mapping[0].Value.Text != "9223372036854775808" {
t.Fatalf("wide number = %#v", root.Mapping[0].Value)
}
if root.Mapping[0].KeyLocation.Line != 2 || root.Mapping[0].ValueLocation.Column != 7 {
t.Fatalf("locations = %#v", root.Mapping[0])
}
if root.Mapping[1].Value.Kind != document.String || root.Mapping[1].Value.Text != "2026-09-06" {
t.Fatalf("timestamp-like text = %#v", root.Mapping[1].Value)
}
if root.Mapping[3].Value.Sequence[1].Kind != document.Null || root.Mapping[3].Value.Sequence[2].Kind != document.Null {
t.Fatalf("null spellings = %#v", root.Mapping[3].Value.Sequence)
}
if root.Mapping[4].Value.Kind != document.Null {
t.Fatalf("empty value = %#v", root.Mapping[4].Value)
}
}
func TestParsePolicyTable(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
code string
}{
{name: "empty input", input: "", code: codeEmpty},
{name: "empty document", input: "---\n", code: codeEmpty},
{name: "sequence root", input: "- value\n", code: codeRootType},
{name: "scalar root", input: "value\n", code: codeRootType},
{name: "second document", input: "a: 1\n---\nb: 2\n", code: codeMultipleDocuments},
{name: "empty second document", input: "a: 1\n---\n", code: codeMultipleDocuments},
{name: "duplicate key", input: "a: 1\na: 2\n", code: codeDuplicateKey},
{name: "nested duplicate", input: "a: {b: 1, b: 2}\n", code: codeDuplicateKey},
{name: "explicit standard tag", input: "a: !!str value\n", code: codeTag},
{name: "explicit custom tag", input: "a: !custom value\n", code: codeTag},
{name: "anchor", input: "a: &name value\n", code: codeAnchor},
{name: "alias source anchor", input: "a: &name value\nb: *name\n", code: codeAnchor},
{name: "merge key", input: "<<: value\n", code: codeMergeKey},
{name: "quoted merge key", input: "\"<<\": value\n", code: codeMergeKey},
{name: "boolean key", input: "true: value\n", code: codeScalar},
{name: "number key", input: "1: value\n", code: codeScalar},
{name: "null key", input: "null: value\n", code: codeScalar},
{name: "legacy yes", input: "a: yes\n", code: codeScalar},
{name: "legacy mixed", input: "a: OfF\n", code: codeScalar},
{name: "reserved case", input: "a: TRUE\n", code: codeScalar},
{name: "hex", input: "a: 0x10\n", code: codeScalar},
{name: "octal", input: "a: 0o10\n", code: codeScalar},
{name: "separator", input: "a: 1_000\n", code: codeScalar},
{name: "leading plus", input: "a: +1\n", code: codeScalar},
{name: "leading zero", input: "a: 01\n", code: codeScalar},
{name: "negative leading zero", input: "a: -01\n", code: codeScalar},
{name: "leading dot", input: "a: .5\n", code: codeScalar},
{name: "trailing dot", input: "a: 1.\n", code: codeScalar},
{name: "incomplete exponent", input: "a: 1e\n", code: codeScalar},
{name: "incomplete signed exponent", input: "a: 1e+\n", code: codeScalar},
{name: "infinity", input: "a: .inf\n", code: codeScalar},
{name: "nan", input: "a: .NaN\n", code: codeScalar},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, issues := Parse(strings.NewReader(test.input), "source-1", generousLimits())
if len(issues) != 1 || issues[0].Code != test.code {
t.Fatalf("issues = %#v, want code %q", issues, test.code)
}
})
}
}
func TestTranslationRejectsAliasNodes(t *testing.T) {
t.Parallel()
node := &yaml.Node{Kind: yaml.AliasNode, Line: 2, Column: 3}
state := translationState{sourceID: "source-1", limits: generousLimits()}
if _, issue := state.translate(node, 0); issue == nil || issue.Code != codeAlias {
t.Fatalf("alias issue = %#v", issue)
}
}
func TestParseAcceptsSupportedSyntax(t *testing.T) {
t.Parallel()
inputs := []string{
"plain: text\nquoted_bool: 'ON'\nquoted_number: \"01\"\nblock: |\n text\n",
"{zero: 0, negative: -1, fraction: 1.25, exponent: 1e+3, boolean: false}\n",
"\"true\": value\n\"1\": number key\n\"null\": null key\n",
}
for _, input := range inputs {
if _, issues := Parse(strings.NewReader(input), "source-1", generousLimits()); len(issues) != 0 {
t.Errorf("Parse(%q) issues = %#v", input, issues)
}
}
}
func TestDuplicateKeyReportsBothLocations(t *testing.T) {
t.Parallel()
_, issues := Parse(strings.NewReader("same: first\nsame: second\n"), "source-1", generousLimits())
if len(issues) != 1 || issues[0].Code != codeDuplicateKey || issues[0].Line != 2 || len(issues[0].Related) != 1 || issues[0].Related[0].Line != 1 {
t.Fatalf("duplicate issues = %#v", issues)
}
}
func TestParseEnforcesTreeAndParserLimits(t *testing.T) {
t.Parallel()
for _, input := range []string{"a:\n b: value\n", "{a: {b: value}}\n"} {
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 2, Nodes: 20, ParserDepth: 20}); len(issues) != 0 {
t.Fatalf("depth boundary rejected for %q: %#v", input, issues)
}
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 1, Nodes: 20, ParserDepth: 20}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
t.Fatalf("depth overflow for %q = %#v", input, issues)
}
}
if _, issues := Parse(strings.NewReader("a: 1\n"), "source-1", Limits{Depth: 2, Nodes: 3, ParserDepth: 20}); len(issues) != 0 {
t.Fatalf("node boundary rejected: %#v", issues)
}
if _, issues := Parse(strings.NewReader("a: 1\n"), "source-1", Limits{Depth: 2, Nodes: 2, ParserDepth: 20}); len(issues) != 1 || issues[0].Code != codeNodeLimit {
t.Fatalf("node overflow = %#v", issues)
}
if _, issues := Parse(strings.NewReader("a:\n b:\n c: value\n"), "source-1", Limits{Depth: 20, Nodes: 20, ParserDepth: 1}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
t.Fatalf("parser depth overflow = %#v", issues)
}
}
func TestParserDepthCalibrationForBlockAndFlow(t *testing.T) {
t.Parallel()
inputs := []string{"a:\n b: value\n", "{a: {b: value}}\n"}
for _, input := range inputs {
maximum := observedParserDepth(t, input)
if maximum > 4 {
t.Fatalf("parser depth %d exceeds root-zero tree depth plus safety offset", maximum)
}
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 2, Nodes: 20, ParserDepth: maximum}); len(issues) != 0 {
t.Fatalf("parser boundary %d rejected %q: %#v", maximum, input, issues)
}
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 20, Nodes: 20, ParserDepth: maximum - 1}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
t.Fatalf("parser overflow at %d for %q: %#v", maximum-1, input, issues)
}
}
}
func observedParserDepth(t *testing.T, input string) int {
t.Helper()
maximum := 0
depthPlugin := limit.New(limit.DepthFunc(func(depth int, _ *limit.DepthContext) error {
maximum = max(maximum, depth)
return nil
}))
loader, err := yaml.NewLoader(strings.NewReader(input), yaml.WithPlugin(depthPlugin))
if err != nil {
t.Fatal(err)
}
var node yaml.Node
if err := loader.Load(&node); err != nil && err != io.EOF {
t.Fatal(err)
}
return maximum
}
func TestMalformedParserDiagnosticIsSafe(t *testing.T) {
t.Parallel()
const canary = "CANARY_SECRET_VALUE"
_, issues := Parse(strings.NewReader("value: ["+canary+"\n"), "source-1", generousLimits())
if len(issues) != 1 || issues[0].Code != codeInvalid || issues[0].Line == 0 {
t.Fatalf("issues = %#v", issues)
}
if strings.Contains(issues[0].Message, canary) {
t.Fatal("parser diagnostic exposed input")
}
}
func generousLimits() Limits {
return Limits{Depth: 32, Nodes: 1_000, ParserDepth: 64}
}

103
internal/yaml/write.go Normal file
View File

@@ -0,0 +1,103 @@
package yaml
import (
"errors"
"io"
"strings"
"go.yaml.in/yaml/v4"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
var errWrite = errors.New("configkit: effective YAML rendering failed")
// Write serializes one owned document tree with fully specified stable formatting.
func Write(output io.Writer, root *document.Node) error {
if output == nil || root == nil {
return errWrite
}
dumper, err := yaml.NewDumper(strictWriter{output},
yaml.WithIndent(2),
yaml.WithCompactSeqIndent(false),
yaml.WithLineWidth(-1),
yaml.WithUnicode(true),
yaml.WithUniqueKeys(true),
yaml.WithCanonical(false),
yaml.WithLineBreak(yaml.LineBreakLN),
yaml.WithExplicitStart(false),
yaml.WithExplicitEnd(false),
yaml.WithFlowSimpleCollections(false),
yaml.WithQuotePreference(yaml.QuoteSingle),
yaml.WithAllDocuments(false),
)
if err != nil {
return errWrite
}
if err := dumper.Dump(adapterNode(root)); err != nil {
return safeWriteError(err)
}
if err := dumper.Close(); err != nil {
return safeWriteError(err)
}
return nil
}
func adapterNode(node *document.Node) *yaml.Node {
result := &yaml.Node{}
switch node.Kind {
case document.Mapping:
result.Kind, result.Tag = yaml.MappingNode, "!!map"
result.Content = make([]*yaml.Node, 0, len(node.Mapping)*2)
for _, entry := range node.Mapping {
result.Content = append(result.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: entry.Key},
adapterNode(entry.Value),
)
}
case document.Sequence:
result.Kind, result.Tag = yaml.SequenceNode, "!!seq"
result.Content = make([]*yaml.Node, len(node.Sequence))
for index, child := range node.Sequence {
result.Content[index] = adapterNode(child)
}
case document.String:
result.Kind, result.Tag, result.Value = yaml.ScalarNode, "!!str", node.Text
if node.Text == "[REDACTED]" {
result.Style = yaml.SingleQuotedStyle
}
case document.Boolean:
result.Kind, result.Tag, result.Value = yaml.ScalarNode, "!!bool", node.Text
case document.Number:
result.Kind, result.Value = yaml.ScalarNode, node.Text
result.Tag = "!!int"
if strings.ContainsAny(node.Text, ".eE") {
result.Tag = "!!float"
}
case document.Null:
result.Kind, result.Tag, result.Value = yaml.ScalarNode, "!!null", "null"
}
return result
}
func safeWriteError(err error) error {
var dumpError *yaml.DumpError
if errors.As(err, &dumpError) && dumpError.Stage == yaml.WriterStage {
if cause := errors.Unwrap(dumpError); cause != nil {
return cause
}
}
return errWrite
}
type strictWriter struct {
output io.Writer
}
func (writer strictWriter) Write(data []byte) (int, error) {
written, err := writer.output.Write(data)
if err == nil && written != len(data) {
err = io.ErrShortWrite
}
return written, err
}

View File

@@ -0,0 +1,59 @@
package yaml
import (
"bytes"
"errors"
"io"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestWriteUsesStableExplicitFormatting(t *testing.T) {
t.Parallel()
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "empty_map", Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{}}},
{Key: "empty_sequence", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{}}},
{Key: "number", Value: &document.Node{Kind: document.Number, Text: "1.25e+2"}},
{Key: "redacted", Value: &document.Node{Kind: document.String, Text: "[REDACTED]"}},
{Key: "sequence", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{
{Kind: document.String, Text: "first"},
{Kind: document.String, Text: "true"},
}}},
{Key: "nil_value", Value: &document.Node{Kind: document.Null}},
}}
var output bytes.Buffer
if err := Write(&output, root); err != nil {
t.Fatal(err)
}
want := "empty_map: {}\nempty_sequence: []\nnumber: 1.25e+2\nredacted: '[REDACTED]'\nsequence:\n - first\n - 'true'\nnil_value: null\n"
if output.String() != want {
t.Fatalf("output:\n%s\nwant:\n%s", output.String(), want)
}
}
func TestWritePropagatesWriterErrorsAndShortWrites(t *testing.T) {
t.Parallel()
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: &document.Node{Kind: document.String, Text: "safe"}}}}
want := errors.New("write failed")
if err := Write(failingWriter{err: want}, root); !errors.Is(err, want) {
t.Fatalf("writer error = %v", err)
}
if err := Write(shortOutput{}, root); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err)
}
}
type failingWriter struct {
err error
}
func (writer failingWriter) Write(data []byte) (int, error) {
return min(2, len(data)), writer.err
}
type shortOutput struct{}
func (shortOutput) Write(data []byte) (int, error) {
return max(0, len(data)-1), nil
}

444
load.go Normal file
View File

@@ -0,0 +1,444 @@
package configkit
import (
"os"
"reflect"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Definition contains reusable application rules for a configuration type.
// Secret is a pure predicate that can add sensitivity to semantic paths.
// Definitions should be treated as read-only after being shared.
type Definition[T any] struct {
Defaults func() T
Resolve func(*T, *Resolution) error
Validate func(*T, *Validation)
Version *VersionRule
Secret func(Path) bool
}
// Options contains the application definition and per-load input selections.
// Imports enables explicit path and glob imports; Profile selects one fixed profile
// overlay; Overrides contains only values explicitly supplied by an application CLI.
type Options[T any] struct {
Definition Definition[T]
Imports bool
Profile string
Filesystem *FilesystemContext
Overrides []Override
}
// FilesystemContext supplies filesystem roots for imports and profiles.
type FilesystemContext struct {
BaseDir string
Root string
}
// Override describes one explicitly supplied application override. Value uses
// parser-neutral built-in scalars, nil, []any, or map[string]any; Flag is a safe
// display name and must not contain a raw argument or value.
type Override struct {
Path Path
Value any
Flag string
}
// Resolution exposes surviving-input presence and records audited application
// resolution changes. A resolver must not retain or concurrently use the context.
type Resolution struct {
state *resolutionState
}
// VersionRule declares the input path and application checker for a schema version.
type VersionRule struct {
Path Path
Check func(Version, *Validation) error
}
// Version preserves the absent, null, and string states of a schema-version input.
type Version struct {
Present bool
Null bool
Text string
}
// Result is a successful independently owned configuration load.
type Result[T any] struct {
Config T
metadata Metadata
sources []SourceInfo
diagnostics []Diagnostic
baseline *document.Node
input *document.Node
final *document.Node
resolution []resolutionSnapshot
applications []historyApplication
secrets map[string]bool
secretReferences map[string]string
}
// Metadata returns detached effective presence, origin, and history records.
func (r *Result[T]) Metadata() Metadata {
if r == nil {
return Metadata{}
}
return cloneMetadata(r.metadata)
}
// Sources returns a detached list of sources used by the load.
func (r *Result[T]) Sources() []SourceInfo {
if r == nil {
return nil
}
return cloneSourceInfos(r.sources)
}
// Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Result[T]) Diagnostics() []Diagnostic {
if r == nil {
return nil
}
return cloneDiagnostics(r.diagnostics)
}
// Load parses and strictly decodes zero or one selected source, explicitly enabled
// imports, and one selected profile. It accepts at most one Options value and
// returns no result when any error is reported.
func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
return loadWithSecretLookup(source, os.LookupEnv, supplied...)
}
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
accumulator := diagnosticAccumulator{}
options, diagnostics := selectOptions(source, supplied)
if len(diagnostics) != 0 {
accumulator.add(diagnostics...)
return nil, accumulator.loadError()
}
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
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)
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))
}
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)
}
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)
}
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) {
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)
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) {
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
}

298
load_metadata.go Normal file
View File

@@ -0,0 +1,298 @@
package configkit
import (
"slices"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func metadataFromDecoded(decoded decodedConfiguration) Metadata {
metadata := Metadata{
fields: make(map[string]FieldInfo),
history: make(map[string][]Change),
}
baseline := make(map[string]*document.Node)
indexDocument(decoded.baseline, Path{}, baseline)
for pointer := range baseline {
path, err := ParsePath(pointer)
if err != nil {
continue
}
metadata.history[pointer] = append(metadata.history[pointer], Change{
Path: path, Origin: Origin{Layer: LayerDefault},
})
}
for pointer := range decoded.presence {
path, err := ParsePath(pointer)
if err != nil {
continue
}
origin := cloneOrigin(decoded.origins[pointer])
change := Change{Path: path, Origin: origin}
if _, ok := baseline[pointer]; ok {
change.Previous = []Origin{{Layer: LayerDefault}}
}
metadata.history[pointer] = append(metadata.history[pointer], change)
}
collectFieldInfo(decoded.final, Path{}, decoded, metadata.fields)
return metadata
}
func applyOverlayMetadata(metadata *Metadata, records []overlayRecord) {
baseline := make(map[string][]Change, len(metadata.history))
for pointer, history := range metadata.history {
for _, change := range history {
if change.Origin.Layer == LayerDefault {
baseline[pointer] = append(baseline[pointer], cloneChange(change))
}
}
}
events := make(map[string][]Change)
for _, record := range records {
exact := false
for _, displaced := range record.displaced {
pointer := displaced.path.String()
appendInputOriginEvent(events, baseline, pointer, displaced.path, displaced.origin)
appendInputChangeEvent(events, baseline, pointer, record.change)
if pointer == record.change.Path.String() {
exact = true
}
}
if !exact {
pointer := record.change.Path.String()
for _, previous := range record.change.Previous {
appendInputOriginEvent(events, baseline, pointer, record.change.Path, previous)
}
appendInputChangeEvent(events, baseline, pointer, record.change)
}
}
for pointer, info := range metadata.fields {
if !info.Explicit {
continue
}
path, err := ParsePath(pointer)
if err != nil {
continue
}
if history := events[pointer]; len(history) != 0 && history[len(history)-1].Origin.Layer == info.Origin.Layer && history[len(history)-1].Origin.Operation == info.Origin.Operation {
continue
}
appendInputOriginEvent(events, baseline, pointer, path, info.Origin)
}
metadata.history = make(map[string][]Change, len(baseline)+len(events))
for pointer, history := range baseline {
metadata.history[pointer] = cloneChanges(history)
}
for pointer, history := range events {
metadata.history[pointer] = append(metadata.history[pointer], cloneChanges(history)...)
}
}
func appendInputOriginEvent(events map[string][]Change, baseline map[string][]Change, pointer string, path Path, origin Origin) {
if origin.Layer == "" {
return
}
history := events[pointer]
if len(history) != 0 && originContainedBy(history[len(history)-1].Origin, origin) {
return
}
appendInputChangeEvent(events, baseline, pointer, Change{Path: path.clone(), Origin: cloneOrigin(origin)})
}
func appendInputChangeEvent(events map[string][]Change, baseline map[string][]Change, pointer string, value Change) {
change := cloneChange(value)
history := events[pointer]
if len(history) != 0 {
change.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)}
} else if defaults := baseline[pointer]; len(defaults) != 0 {
change.Previous = []Origin{cloneOrigin(defaults[len(defaults)-1].Origin)}
} else {
change.Previous = nil
}
events[pointer] = append(history, change)
}
func originContainedBy(candidate, container Origin) bool {
if candidate.Layer != container.Layer || candidate.Operation != container.Operation {
return false
}
for _, location := range candidate.Locations {
found := false
for _, available := range container.Locations {
if location == available {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func cloneChanges(changes []Change) []Change {
cloned := make([]Change, len(changes))
for index, change := range changes {
cloned[index] = cloneChange(change)
}
return cloned
}
func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) {
pointer := path.String()
_, explicit := decoded.presence[pointer]
fields[pointer] = FieldInfo{
Explicit: explicit,
Null: node.Kind == document.Null,
Origin: cloneOrigin(decoded.origins[pointer]),
}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectFieldInfo(entry.Value, path.Key(entry.Key), decoded, fields)
}
case document.Sequence:
for index, child := range node.Sequence {
collectFieldInfo(child, path.Index(index), decoded, fields)
}
}
}
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{}
}
cloned := Metadata{
fields: make(map[string]FieldInfo, len(metadata.fields)),
history: make(map[string][]Change, len(metadata.history)),
}
for path, info := range metadata.fields {
cloned.fields[path] = cloneFieldInfo(info)
}
for path, changes := range metadata.history {
clonedChanges := make([]Change, len(changes))
for index, change := range changes {
clonedChanges[index] = cloneChange(change)
}
cloned.history[path] = clonedChanges
}
return cloned
}
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
for _, snapshot := range resolved.snapshots {
pointer := snapshot.change.Path.String()
change := cloneChange(snapshot.change)
if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)}
info.Origin = cloneOrigin(change.Origin)
metadata.fields[pointer] = info
}
metadata.history[pointer] = append(metadata.history[pointer], change)
}
appendResolutionDescendantHistory(metadata, resolved)
for _, delta := range resolved.deltas {
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
if record == nil {
continue
}
for pointer, info := range metadata.fields {
path, err := ParsePath(pointer)
if err != nil || !pathContains(delta.path, path) {
continue
}
info.Origin = cloneOrigin(record.change.Origin)
metadata.fields[pointer] = info
}
}
refreshResolutionFields(metadata.fields, resolved.final, resolved.snapshots)
}
func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionResult) {
finalPaths := make(map[string]*document.Node)
indexDocument(resolved.final, Path{}, finalPaths)
for _, delta := range resolved.deltas {
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
if record == nil {
continue
}
affected := make(map[string]bool)
for pointer := range metadata.history {
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) {
affected[pointer] = true
}
}
for pointer := range finalPaths {
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) {
affected[pointer] = true
}
}
pointers := make([]string, 0, len(affected))
for pointer := range affected {
if pointer != record.change.Path.String() {
pointers = append(pointers, pointer)
}
}
slices.Sort(pointers)
for _, pointer := range pointers {
change := cloneChange(record.change)
if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)}
}
metadata.history[pointer] = append(metadata.history[pointer], change)
}
}
}
func coveringResolutionSnapshot(snapshots []resolutionSnapshot, path Path) *resolutionSnapshot {
var covering *resolutionSnapshot
for index := range snapshots {
snapshot := &snapshots[index]
if pathContains(snapshot.change.Path, path) && (covering == nil || len(snapshot.change.Path.segments) > len(covering.change.Path.segments)) {
covering = snapshot
}
}
return covering
}
func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node, snapshots []resolutionSnapshot) {
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
for pointer := range fields {
if _, ok := finalPaths[pointer]; !ok {
delete(fields, pointer)
}
}
for pointer, node := range finalPaths {
if info, ok := fields[pointer]; ok {
info.Null = node.Kind == document.Null
fields[pointer] = info
continue
}
origin := Origin{Layer: LayerResolution}
if path, err := ParsePath(pointer); err == nil {
if covering := coveringResolutionSnapshot(snapshots, path); covering != nil {
origin = cloneOrigin(covering.change.Origin)
}
}
fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin}
}
}

295
load_test.go Normal file
View File

@@ -0,0 +1,295 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
)
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
t.Parallel()
type config struct {
Name string `yaml:"name"`
}
input := []byte("name: configured\n")
temporary := t.TempDir()
filename := filepath.Join(temporary, "config.yml")
if err := os.WriteFile(filename, input, 0o600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
source func() Source
want string
wantSources int
}{
{name: "file", source: func() Source { return File(filename) }, want: "configured", wantSources: 1},
{name: "bytes", source: func() Source { return Bytes("memory.yml", input) }, want: "configured", wantSources: 1},
{name: "reader", source: func() Source { return Reader("reader.yml", strings.NewReader(string(input))) }, want: "configured", wantSources: 1},
{name: "no source", source: NoSource, want: "default", wantSources: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var defaultsCalls atomic.Int32
result, err := Load(test.source(), Options[config]{Definition: Definition[config]{
Defaults: func() config {
defaultsCalls.Add(1)
return config{Name: "default"}
},
}})
if err != nil {
t.Fatal(err)
}
if result.Config.Name != test.want || defaultsCalls.Load() != 1 || len(result.Sources()) != test.wantSources {
t.Fatalf("result = %#v, calls = %d, sources = %#v", result.Config, defaultsCalls.Load(), result.Sources())
}
})
}
}
func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
t.Parallel()
type config struct {
SchemaVersion *string `yaml:"schema_version"`
}
versionPath := Keys("schema_version")
tests := []struct {
name string
source Source
want Version
wantLine int
wantErr Code
returnOnly bool
}{
{name: "absent", source: Bytes("absent.yml", []byte("{}\n")), want: Version{}, wantLine: 1},
{name: "no source absent", source: NoSource(), want: Version{}},
{name: "null", source: Bytes("null.yml", []byte("schema_version: null\n")), want: Version{Present: true, Null: true}, wantLine: 1},
{name: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1},
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), wantErr: CodeVersionType},
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
called := false
rule := &VersionRule{Path: versionPath, Check: func(version Version, validation *Validation) error {
called = true
if test.returnOnly {
return errors.New("CANARY_VERSION_ERROR")
}
if version != test.want {
t.Errorf("version = %#v, want %#v", version, test.want)
}
validation.Warning(versionPath, "review schema compatibility", Keys("missing_related"))
return nil
}}
result, err := Load(test.source, Options[config]{Definition: Definition[config]{Version: rule}})
if test.wantErr != "" {
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantErr || strings.Contains(diagnostics[0].Message, "CANARY") {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if test.wantErr == CodeVersionType && called {
t.Fatal("checker ran for a non-string version")
}
return
}
if err != nil {
t.Fatal(err)
}
diagnostics := result.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning || diagnostics[0].Severity != SeverityWarning {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if test.wantLine == 0 && (diagnostics[0].Primary != nil || len(diagnostics[0].Related) != 0) {
t.Fatalf("synthetic diagnostic locations = %#v", diagnostics[0])
}
if test.wantLine != 0 && (diagnostics[0].Primary == nil || diagnostics[0].Primary.Line != test.wantLine || len(diagnostics[0].Related) != 1) {
t.Fatalf("diagnostic locations = %#v", diagnostics[0])
}
})
}
}
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
t.Parallel()
type config struct {
Name string `yaml:"name"`
}
var defaultsCalls atomic.Int32
result, err := Load(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config {
defaultsCalls.Add(1)
return config{Name: "default"}
},
Version: &VersionRule{Path: Keys("schema_version"), Check: func(_ Version, validation *Validation) error {
validation.Error(Keys("schema_version"), "schema_version is required")
return errors.New("CANARY_RETURNED_ERROR")
}},
}})
if result != nil || err == nil || defaultsCalls.Load() != 0 {
t.Fatalf("result = %#v, err = %v, calls = %d", result, err, defaultsCalls.Load())
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionError || strings.Contains(diagnostics[0].Message, "CANARY") {
t.Fatalf("diagnostics = %#v", diagnostics)
}
}
func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
t.Parallel()
type nested struct {
Kept string `yaml:"kept"`
Set string `yaml:"set"`
}
type config struct {
Nested nested `yaml:"nested"`
Nullable map[string]string `yaml:"nullable"`
}
result, err := Load(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config {
return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}}
},
}})
if err != nil {
t.Fatal(err)
}
metadata := result.Metadata()
kept, ok := metadata.Lookup(Keys("nested", "kept"))
if !ok || kept.Explicit || kept.Origin.Layer != LayerDefault {
t.Fatalf("kept = %#v, %v", kept, ok)
}
set, ok := metadata.Lookup(Keys("nested", "set"))
if !ok || !set.Explicit || set.Origin.Layer != LayerBase || len(set.Origin.Locations) != 1 {
t.Fatalf("set = %#v, %v", set, ok)
}
nullable, ok := metadata.Lookup(Keys("nullable"))
if !ok || !nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerBase {
t.Fatalf("nullable = %#v, %v", nullable, ok)
}
if metadata.IsExplicit(Keys("nested", "kept")) || !metadata.IsExplicit(Keys("nested", "set")) {
t.Fatal("explicit presence disagrees with lookup")
}
if history := metadata.History(Keys("nested", "set")); len(history) != 2 || history[0].Origin.Layer != LayerDefault || history[1].Origin.Layer != LayerBase || len(history[1].Previous) != 1 {
t.Fatalf("history = %#v", history)
}
set.Origin.Locations[0].Line = 99
history := metadata.History(Keys("nested", "set"))
history[1].Origin.Locations[0].Line = 99
sources := result.Sources()
sources[0].Label = "changed"
diagnostics := result.Diagnostics()
if diagnostics != nil {
diagnostics[0].Message = "changed"
}
fresh, _ := result.Metadata().Lookup(Keys("nested", "set"))
if fresh.Origin.Locations[0].Line == 99 || result.Sources()[0].Label != "metadata.yml" || result.Diagnostics() != nil {
t.Fatal("result inspection aliases internal records")
}
}
func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
t.Parallel()
type config struct {
Name string `yaml:"name"`
}
validSource := func() Source { return Bytes("options.yml", []byte("name: value\n")) }
tests := []struct {
name string
source Source
options []Options[config]
code Code
}{
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
{name: "imports without filesystem context", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsInvalid},
{name: "incomplete filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
{name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid},
{name: "no-source filesystem", source: NoSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(test.source, test.options...)
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.code {
t.Fatalf("error = %#v", err)
}
})
}
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)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatal(err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/_configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
t.Fatalf("diagnostic = %#v", diagnostic)
}
}
func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) {
t.Parallel()
type invalidSchema struct {
Channel chan int `yaml:"channel"`
}
type validConfig struct {
Known int `yaml:"known"`
}
tests := []struct {
name string
run func() (any, error)
code Code
}{
{name: "source", run: func() (any, error) { return Load[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing},
{name: "schema", run: func() (any, error) { return Load[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType},
{name: "decode", run: func() (any, error) { return Load[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := test.run()
if !isNilInterface(result) || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != test.code {
t.Fatalf("error = %#v", err)
}
})
}
}
func isNilInterface(value any) bool {
if value == nil {
return true
}
reflected := reflect.ValueOf(value)
return reflected.Kind() == reflect.Pointer && reflected.IsNil()
}

47
metadata.go Normal file
View File

@@ -0,0 +1,47 @@
package configkit
// 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
}
// Lookup returns information for an effective path. Unknown and invalid paths
// return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() {
return FieldInfo{}, false
}
info, ok := m.fields[path.String()]
if !ok {
return FieldInfo{}, false
}
return cloneFieldInfo(info), true
}
// IsExplicit reports whether a surviving input contribution explicitly defined
// path.
func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path)
return ok && info.Explicit
}
// History returns detached definition and resolution records for path. A record's
// Path can be an ancestor when that container operation replaced or removed path.
// 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() {
return nil
}
history, ok := m.history[path.String()]
if !ok {
return nil
}
cloned := make([]Change, len(history))
for i, change := range history {
cloned[i] = cloneChange(change)
}
return cloned
}

58
metadata_test.go Normal file
View File

@@ -0,0 +1,58 @@
package configkit
import "testing"
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Parallel()
path := Keys("service", "workers")
metadata := Metadata{
fields: map[string]FieldInfo{
path.String(): {
Explicit: true,
Origin: Origin{
Layer: LayerBase,
Locations: []Location{{SourceID: "source-1", Line: 2, Column: 3}},
},
},
},
history: map[string][]Change{
path.String(): {{
Path: path,
Origin: Origin{Layer: LayerResolution, Locations: []Location{{SourceID: "source-1"}}},
Previous: []Origin{{Layer: LayerDefault, Locations: []Location{{SourceID: "default"}}}},
Reasons: []string{"bounded worker count"},
Dependencies: []Path{Keys("limits")},
}},
},
}
info, ok := metadata.Lookup(path)
if !ok || !info.Explicit || !metadata.IsExplicit(path) {
t.Fatalf("Lookup() = %#v, %v", info, ok)
}
info.Origin.Locations[0].SourceID = "mutated"
again, _ := metadata.Lookup(path)
if again.Origin.Locations[0].SourceID != "source-1" {
t.Fatal("Lookup returned aliased locations")
}
history := metadata.History(path)
history[0].Reasons[0] = "mutated"
history[0].Previous[0].Locations[0].SourceID = "mutated"
history[0].Dependencies[0].segments[0].key = "mutated"
againHistory := metadata.History(path)
if againHistory[0].Reasons[0] != "bounded worker count" ||
againHistory[0].Previous[0].Locations[0].SourceID != "default" ||
againHistory[0].Dependencies[0].String() != "/limits" {
t.Fatal("History returned aliased nested records")
}
missing := Keys("missing")
if _, ok := metadata.Lookup(missing); ok || metadata.IsExplicit(missing) || metadata.History(missing) != nil {
t.Fatal("unknown path produced metadata")
}
if _, ok := metadata.Lookup(Path{}.Index(-1)); ok {
t.Fatal("invalid path produced metadata")
}
}

174
overlay.go Normal file
View File

@@ -0,0 +1,174 @@
package configkit
import (
"slices"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type overlayRecord struct {
change Change
displaced []displacedDefinition
before *document.Node
after *document.Node
}
type displacedDefinition struct {
path Path
origin Origin
}
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
return nil, []Diagnostic{{
Code: CodeProfileInvalid, Severity: SeverityError,
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,
}
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})
return result, nil
}
func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path, layer Layer, recordReplacements bool) {
for _, incoming := range overlay.Mapping {
path := current.Key(incoming.Key)
index := documentEntryIndex(target.Mapping, incoming.Key)
if index < 0 {
cloned := incoming
cloned.Value = document.Clone(incoming.Value)
target.Mapping = append(target.Mapping, cloned)
collectOverlayOrigins(result.origins, incoming.Value, path, layer)
continue
}
existing := target.Mapping[index].Value
if existing.Kind == document.Mapping && incoming.Value.Kind == document.Mapping {
mergeOverlayOrigin(result.origins, path, incoming.Value.Location, layer)
mergeOverlayMappings(result, existing, incoming.Value, path, layer, recordReplacements)
continue
}
displaced := displacedOrigins(existing, result.origins, path)
previous := cloneOrigin(result.origins[path.String()])
deleteOriginSubtree(result.origins, path)
target.Mapping[index] = incoming
target.Mapping[index].Value = document.Clone(incoming.Value)
collectOverlayOrigins(result.origins, incoming.Value, path, layer)
if recordReplacements {
result.overlays = append(result.overlays, overlayRecord{
change: Change{
Path: path.clone(), Origin: overlayOrigin(layer, incoming.Value.Location),
Previous: previousOrigins(previous),
},
displaced: displaced,
before: document.Clone(existing), after: document.Clone(incoming.Value),
})
}
}
}
func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path, layer Layer) {
origins[path.String()] = overlayOrigin(layer, node.Location)
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectOverlayOrigins(origins, entry.Value, path.Key(entry.Key), layer)
}
case document.Sequence:
for index, child := range node.Sequence {
collectOverlayOrigins(origins, child, path.Index(index), layer)
}
}
}
func mergeOverlayOrigin(origins map[string]Origin, path Path, location document.Location, layer Layer) {
pointer := path.String()
origin := cloneOrigin(origins[pointer])
origin.Layer = layer
origin.Locations = append(origin.Locations, publicLocation(location))
origins[pointer] = origin
}
func overlayOrigin(layer Layer, location document.Location) Origin {
return Origin{Layer: layer, Locations: []Location{publicLocation(location)}}
}
func previousOrigins(origin Origin) []Origin {
if origin.Layer == "" {
return nil
}
return []Origin{origin}
}
func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
var displaced []displacedDefinition
collectDisplacedOrigins(&displaced, node, origins, path, Origin{})
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
return strings.Compare(left.path.String(), right.path.String())
})
return displaced
}
func collectDisplacedOrigins(displaced *[]displacedDefinition, node *document.Node, origins map[string]Origin, path Path, inherited Origin) {
origin, ok := origins[path.String()]
if !ok {
origin = cloneOrigin(inherited)
origin.Locations = []Location{publicLocation(node.Location)}
}
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origin)})
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectDisplacedOrigins(displaced, entry.Value, origins, path.Key(entry.Key), origin)
}
case document.Sequence:
for index, child := range node.Sequence {
collectDisplacedOrigins(displaced, child, origins, path.Index(index), origin)
}
}
}
func deleteOriginSubtree(origins map[string]Origin, path Path) {
prefix := path.String()
for pointer := range origins {
if pointer == prefix || strings.HasPrefix(pointer, prefix+"/") {
delete(origins, pointer)
}
}
}
func cloneOriginIndex(origins map[string]Origin) map[string]Origin {
cloned := make(map[string]Origin, len(origins))
for pointer, origin := range origins {
cloned[pointer] = cloneOrigin(origin)
}
return cloned
}
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{
path: displaced.path.clone(), origin: cloneOrigin(displaced.origin),
}
}
}
return cloned
}

78
overlay_test.go Normal file
View File

@@ -0,0 +1,78 @@
package configkit
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees(t *testing.T) {
baseRoot := parseCompositionDocument(t, "source-1", `shared:
kept: base
changed: old
sequence: [one, two]
nullable: value
to_map: scalar
to_scalar: {child: old}
`)
base, diagnostics := composeDocuments(baseRoot)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
overlay := parseCompositionDocument(t, "source-2", `shared:
changed: new
added: profile
sequence: []
nullable: null
to_map: {child: new}
to_scalar: scalar
`)
baseBefore := document.Clone(base.root)
overlayBefore := document.Clone(overlay)
result, diagnostics := overlayDocuments(base, overlay)
if len(diagnostics) != 0 {
t.Fatalf("overlayDocuments() diagnostics = %#v", diagnostics)
}
checks := []struct {
path Path
kind document.Kind
text string
}{
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
{path: Keys("shared", "added"), kind: document.String, text: "profile"},
{path: Keys("sequence"), kind: document.Sequence},
{path: Keys("nullable"), kind: document.Null},
{path: Keys("to_map"), kind: document.Mapping},
{path: Keys("to_map", "child"), kind: document.String, text: "new"},
{path: Keys("to_scalar"), kind: document.String, text: "scalar"},
}
for _, check := range checks {
node, ok := documentAtPath(result.root, check.path)
if !ok || node.Kind != check.kind || node.Text != check.text {
t.Errorf("node %s = %#v, %v", check.path.String(), node, ok)
}
}
if _, exists := result.origins["/sequence/0"]; exists {
t.Fatal("replaced empty sequence retained a stale index origin")
}
if _, exists := result.origins["/to_scalar/child"]; exists {
t.Fatal("parent replacement retained a stale child origin")
}
shared := result.origins["/shared"]
if shared.Layer != LayerProfile || len(shared.Locations) != 2 {
t.Fatalf("shared mapping origin = %#v", shared)
}
if len(result.overlays) != 5 {
t.Fatalf("overlay records = %#v, want five replacements", result.overlays)
}
if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) {
t.Fatal("overlay mutated an input document")
}
result.root.Mapping[0].Value.Mapping[0].Value.Text = "changed"
if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) {
t.Fatal("overlay result aliases an input document")
}
}

462
override.go Normal file
View File

@@ -0,0 +1,462 @@
package configkit
import (
"math"
"reflect"
"slices"
"strconv"
"strings"
"unicode"
"unicode/utf8"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const maxOverrides = 1024
type preparedOverride struct {
path Path
value *document.Node
source SourceInfo
bytes int64
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
}
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
return nil, cloneSourceInfos(sources), []Diagnostic{{
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
Message: "configuration overrides exceed the record or source-count limit",
}}
}
var diagnostics []Diagnostic
for index, override := range overrides {
if !validOverrideLabel(override.Flag) {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideInvalidLabel, Severity: SeverityError,
Message: "override record " + strconv.Itoa(index+1) + " requires a safe nonempty flag name",
})
}
}
if len(diagnostics) != 0 {
return nil, cloneSourceInfos(sources), canonicalDiagnostics(diagnostics)
}
prepared := make([]preparedOverride, len(overrides))
allSources := cloneSourceInfos(sources)
for index, override := range overrides {
sourceID := "source-" + strconv.Itoa(len(sources)+index+1)
info := SourceInfo{ID: sourceID, Label: override.Flag, Layer: LayerCLI}
allSources = append(allSources, info)
prepared[index].source = info
}
seen := make(map[string]int, len(overrides))
pathAccepted := make([]bool, len(overrides))
var usedBytes int64
usedNodes := 0
for index, override := range overrides {
location := &Location{SourceID: prepared[index].source.ID}
if issue := validateOverridePath(override.Path, base.root); issue != nil {
issue.Primary = location
diagnostics = append(diagnostics, *issue)
continue
}
pointer := override.Path.String()
if previous, duplicate := seen[pointer]; duplicate {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideDuplicate, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration override path is repeated", Primary: location,
Related: []Location{{SourceID: prepared[previous].source.ID}},
})
continue
}
seen[pointer] = index
if conflicting := overlappingOverride(overrides[:index], pathAccepted[:index], override.Path); conflicting >= 0 {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideOverlap, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration override paths overlap", Primary: location,
Related: []Location{{SourceID: prepared[conflicting].source.ID}},
})
continue
}
if rule != nil && (pathContains(override.Path, rule.Path) || pathContains(rule.Path, override.Path)) {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideVersion, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides cannot replace the schema-version path", Primary: location,
})
continue
}
if pathContainsDirective(override.Path) {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideDirective, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides cannot create configuration directives", Primary: location,
})
continue
}
pathAccepted[index] = true
builder := overrideValueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)}
value, issue := builder.build(override.Value, override.Path, len(override.Path.segments))
if issue != nil {
issue.Primary = location
diagnostics = append(diagnostics, *issue)
continue
}
if relative, ok := overrideDirectivePath(value, Path{}); ok {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideDirective, Severity: SeverityError, Path: appendOverridePath(override.Path, relative),
Message: "configuration overrides cannot create configuration directives", Primary: location,
})
continue
}
nodes := builder.nodes + len(override.Path.segments)*2
if nodes > limits.nodes || base.inputNodes+usedNodes+nodes > limits.nodes {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideLimitExceeded, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides exceed the aggregate node limit", Primary: location,
})
continue
}
if builder.bytes > maxSourceBytes || base.inputBytes+usedBytes+builder.bytes > limits.bytes {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideLimitExceeded, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides exceed a record or aggregate string-byte limit", Primary: location,
})
continue
}
prepared[index].path = override.Path.clone()
prepared[index].value = value
prepared[index].bytes = builder.bytes
prepared[index].nodes = nodes
usedBytes += builder.bytes
usedNodes += nodes
}
if len(diagnostics) != 0 {
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
}
func validOverrideLabel(label string) bool {
if label == "" || strings.TrimSpace(label) != label || !utf8.ValidString(label) {
return false
}
for _, character := range label {
if unicode.IsControl(character) {
return false
}
}
return true
}
func validateOverridePath(path Path, root *document.Node) *Diagnostic {
if !path.valid() || len(path.segments) == 0 {
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path must be a valid non-root mapping path"}
}
current := root
for index, segment := range path.segments {
if segment.kind == pathSegmentIndex {
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override paths cannot contain sequence indices"}
}
if current == nil {
continue
}
if current.Kind != document.Mapping {
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path traverses a non-mapping value"}
}
position := documentEntryIndex(current.Mapping, segment.key)
if index == len(path.segments)-1 {
continue
}
if position < 0 {
current = nil
continue
}
current = current.Mapping[position].Value
}
return nil
}
func overlappingOverride(previous []Override, valid []bool, path Path) int {
for index, override := range previous {
if valid[index] && (pathContains(override.Path, path) || pathContains(path, override.Path)) {
return index
}
}
return -1
}
func pathContainsDirective(path Path) bool {
for _, segment := range path.segments {
if segment.key == "_configkit" {
return true
}
}
return false
}
func appendOverridePath(base, relative Path) Path {
result := base.clone()
result.segments = append(result.segments, clonePathSegments(relative.segments, 0)...)
return result
}
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" {
return child, true
}
if found, ok := overrideDirectivePath(entry.Value, child); ok {
return found, true
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if found, ok := overrideDirectivePath(child, path.Index(index)); ok {
return found, true
}
}
}
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
}

307
override_test.go Normal file
View File

@@ -0,0 +1,307 @@
package configkit
import (
"errors"
"math"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type overrideLifecycleSettings struct {
Target string `yaml:"target"`
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
Text string `yaml:"text"`
Items []string `yaml:"items"`
Maybe *string `yaml:"maybe"`
Labels map[string]string `yaml:"labels"`
APIKey Secret `yaml:"api_key"`
}
type overrideLifecycleConfig struct {
Version string `yaml:"version"`
Settings overrideLifecycleSettings `yaml:"settings"`
}
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
directory := t.TempDir()
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
enabled: true
text: base
items: [base]
maybe: base
labels: {base: retained}
api_key: BASE_SECRET
`)
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `settings:
target: profile
labels: {profile: retained}
`)
validated := false
overrides := []Override{
{Path: Keys("settings", "target"), Value: " cli ", Flag: "--target"},
{Path: Keys("settings", "count"), Value: 0, Flag: "--count"},
{Path: Keys("settings", "enabled"), Value: false, Flag: "--enabled"},
{Path: Keys("settings", "text"), Value: "", Flag: "--text"},
{Path: Keys("settings", "items"), Value: []any{}, Flag: "--items"},
{Path: Keys("settings", "maybe"), Value: nil, Flag: "--maybe"},
{Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"},
{Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"},
}
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "CLI_SECRET" {
t.Fatalf("looked up superseded secret name %q", name)
}
return "credential-value", true
}, Options[overrideLifecycleConfig]{
Imports: true, Profile: "testing", Overrides: overrides,
Definition: Definition[overrideLifecycleConfig]{
Defaults: func() overrideLifecycleConfig {
return overrideLifecycleConfig{Settings: overrideLifecycleSettings{Target: "default", Count: 9, Text: "default", Items: []string{"default"}}}
},
Version: &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
if version.Text != "v1" {
validation.Error(Keys("version"), "unsupported version")
}
return nil
}},
Resolve: func(configuration *overrideLifecycleConfig, resolution *Resolution) error {
Normalize(resolution, Keys("settings", "target"), &configuration.Settings.Target, strings.TrimSpace(configuration.Settings.Target), "trimmed target")
return nil
},
Validate: func(configuration *overrideLifecycleConfig, validation *Validation) {
validated = configuration.Settings.Target == "cli" && configuration.Settings.APIKey.Value() == "credential-value"
},
},
})
if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err)
}
want := overrideLifecycleSettings{
Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"},
APIKey: result.Config.Settings.APIKey,
}
if !validated || result.Config.Settings.Count != 0 || result.Config.Settings.Enabled || result.Config.Settings.Text != "" ||
len(result.Config.Settings.Items) != 0 || result.Config.Settings.Items == nil || result.Config.Settings.Maybe != nil ||
result.Config.Settings.Target != want.Target || !reflect.DeepEqual(result.Config.Settings.Labels, want.Labels) {
t.Fatalf("effective settings = %#v", result.Config.Settings)
}
sources := result.Sources()
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
for index, override := range overrides {
if sources[index+3].Layer != LayerCLI || sources[index+3].Label != override.Flag || sources[index+3].Path != "" {
t.Fatalf("CLI source %d = %#v", index, sources[index+3])
}
}
history := result.Metadata().History(Keys("settings", "target"))
wantLayers := []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}
if got := historyLayers(history); !reflect.DeepEqual(got, wantLayers) {
t.Fatalf("target history layers = %#v, want %#v; history %#v", got, wantLayers, history)
}
}
func TestOverridesSupportDefaultsOnlyMapMergeListReplacementAndNumericKeys(t *testing.T) {
type config struct {
Labels map[string]string `yaml:"labels"`
Items []int `yaml:"items"`
Numbers map[string]int `yaml:"numbers"`
Dynamic any `yaml:"dynamic"`
}
labels := map[string]any{"0": "zero", "new": "value"}
items := []any{0, 2}
dynamic := map[string]any{
"int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false,
"null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"},
}
result, err := Load(NoSource(), Options[config]{
Definition: Definition[config]{Defaults: func() config {
return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}}
}},
Overrides: []Override{
{Path: parseOverrideTestPath(t, "/labels"), Value: labels, Flag: "--labels"},
{Path: Keys("items"), Value: items, Flag: "--items"},
{Path: parseOverrideTestPath(t, "/numbers/0"), Value: 7, Flag: "--number"},
{Path: Keys("dynamic"), Value: dynamic, Flag: "--dynamic"},
},
})
labels["new"] = "mutated"
items[0] = 99
dynamic["int"] = 99
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.Config.Labels, map[string]string{"kept": "default", "0": "zero", "new": "value"}) || !reflect.DeepEqual(result.Config.Items, []int{0, 2}) || result.Config.Numbers["0"] != 7 || result.Config.Dynamic.(map[string]any)["int"] != int64(1) {
t.Fatalf("defaults-only overrides = %#v", result.Config)
}
var effective strings.Builder
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), "mutated") || !strings.Contains(effective.String(), "new: value") {
t.Fatalf("detached effective output = %q", effective.String())
}
if info, ok := result.Metadata().Lookup(Keys("labels", "0")); !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
t.Fatalf("numeric mapping key metadata = %#v, %v", info, ok)
}
}
func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testing.T) {
type config struct {
Version string `yaml:"version"`
Scalar string `yaml:"scalar"`
Items []string `yaml:"items"`
Nested map[string]any `yaml:"nested"`
Unknown string `yaml:"unknown"`
}
base := Bytes("override-errors.yml", []byte("version: v1\nscalar: value\nitems: [one]\nnested: {}\n"))
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
cycle := map[string]any{}
cycle["self"] = cycle
deep := any("leaf")
for range maxDocumentDepth + 1 {
deep = []any{deep}
}
for _, test := range []struct {
name string
overrides []Override
code Code
}{
{name: "root", overrides: []Override{{Path: Keys(), Value: "x", Flag: "--root"}}, code: CodeOverrideInvalidPath},
{name: "index", overrides: []Override{{Path: Keys("items").Index(0), Value: "x", Flag: "--item"}}, code: CodeOverrideInvalidPath},
{name: "parsed sequence index", overrides: []Override{{Path: parseOverrideTestPath(t, "/items/0"), Value: "x", Flag: "--item"}}, code: CodeOverrideInvalidPath},
{name: "scalar ancestor", overrides: []Override{{Path: Keys("scalar", "child"), Value: "x", Flag: "--child"}}, code: CodeOverrideInvalidPath},
{name: "duplicate", overrides: []Override{{Path: Keys("scalar"), Value: "a", Flag: "--first"}, {Path: Keys("scalar"), Value: "b", Flag: "--second"}}, code: CodeOverrideDuplicate},
{name: "overlap", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{}, Flag: "--nested"}, {Path: Keys("nested", "child"), Value: "x", Flag: "--child"}}, code: CodeOverrideOverlap},
{name: "unsupported", overrides: []Override{{Path: Keys("scalar"), Value: time.Second, Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
{name: "cycle", overrides: []Override{{Path: Keys("nested"), Value: cycle, Flag: "--nested"}}, code: CodeOverrideInvalidValue},
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
{name: "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: "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},
} {
t.Run(test.name, func(t *testing.T) {
result, err := Load(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
if result != nil {
t.Fatalf("result = %#v, want nil", result)
}
loadError := requireSecretLoadCode(t, err, test.code)
if strings.Contains(fmtLoadError(loadError), strings.Repeat("x", 64)) {
t.Fatal("override diagnostic leaked a raw value")
}
})
}
}
func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
type schemaConfig struct {
Version string `yaml:"version"`
}
type config struct {
Schema schemaConfig `yaml:"schema"`
}
_, err := Load(Bytes("version.yml", []byte("schema: {version: v1}\n")), Options[config]{
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
})
requireSecretLoadCode(t, err, CodeOverrideVersion)
}
func TestOverrideDecodeFailureUsesFlagSourceWithoutRawValue(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
result, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing",
}}})
if result != nil {
t.Fatalf("result = %#v", result)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error = %T", err)
}
diagnostics := loadError.Diagnostics()
sources := loadError.Sources()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeDecodeUnknownField || diagnostics[0].Primary == nil ||
len(sources) != 2 || sources[1].Label != "--missing" || diagnostics[0].Primary.SourceID != sources[1].ID {
t.Fatalf("diagnostics/sources = %#v / %#v", diagnostics, sources)
}
if strings.Contains(fmtLoadError(loadError), "RAW-ARG-CANARY") {
t.Fatal("decode failure leaked override value")
}
}
func TestOverrideTypeMismatchUsesNormalStrictDecode(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
_, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("count"), Value: "not-an-integer", Flag: "--count",
}}})
loadError := requireSecretLoadCode(t, err, CodeDecodeType)
if loadError.Diagnostics()[0].Primary == nil || loadError.Sources()[1].Label != "--count" || strings.Contains(fmtLoadError(loadError), "not-an-integer") {
t.Fatalf("type mismatch diagnostics/sources = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
}
}
func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
result, err := Load(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
if err != nil {
t.Fatal(err)
}
if got := historyLayers(result.Metadata().History(Keys("value"))); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerCLI}) {
t.Fatalf("equal override history = %#v", result.Metadata().History(Keys("value")))
}
}
func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) {
base, diagnostics := composeDocuments(&document.Node{Kind: document.Mapping})
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base.inputNodes = 1
result, sources, diagnostics := applyOverrides(base, nil, []Override{{Path: Keys("value"), Value: "x", Flag: "--value"}}, nil, importLimits{sources: 1, bytes: 1, nodes: 2})
if result != nil || len(sources) != 1 || len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded || len(base.root.Mapping) != 0 {
t.Fatalf("bounded apply = %#v, %#v, %#v; base %#v", result, sources, diagnostics, base.root)
}
}
func historyLayers(history []Change) []Layer {
layers := make([]Layer, len(history))
for index, change := range history {
layers[index] = change.Origin.Layer
}
return layers
}
func parseOverrideTestPath(t *testing.T, value string) Path {
t.Helper()
path, err := ParsePath(value)
if err != nil {
t.Fatal(err)
}
return path
}
func fmtLoadError(loadError *LoadError) string {
var output strings.Builder
_ = WriteDiagnostics(&output, loadError.Diagnostics())
return loadError.Error() + output.String()
}

67
parse.go Normal file
View File

@@ -0,0 +1,67 @@
package configkit
import (
"bytes"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
const (
maxDocumentDepth = 256
maxDocumentNodes = 1_000_000
)
type parseLimits struct {
source sourceLimits
document yamladapter.Limits
}
func defaultParseLimits() parseLimits {
return parseLimits{
source: defaultSourceLimits(),
document: yamladapter.Limits{
Depth: maxDocumentDepth,
Nodes: maxDocumentNodes,
},
}
}
type parsedSource struct {
document *document.Node
source *SourceInfo
bytes int64
}
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
read, diagnostics := readSource(source, sourceID, limits.source)
if len(diagnostics) != 0 || read.info == nil {
return parsedSource{source: read.info}, diagnostics
}
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{document: root, source: read.info, bytes: int64(len(read.data))}, nil
}

38
parse_test.go Normal file
View File

@@ -0,0 +1,38 @@
package configkit
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
"gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
func TestParseSourceOwnsDocumentAndSafeDiagnostics(t *testing.T) {
t.Parallel()
limits := defaultParseLimits()
parsed, diagnostics := parseSource(Bytes("memory.yml", []byte("name: config\n")), "source-1", limits)
if len(diagnostics) != 0 || parsed.source == nil || parsed.source.Label != "memory.yml" || parsed.document.Kind != document.Mapping {
t.Fatalf("parse result = %#v, diagnostics = %#v", parsed, diagnostics)
}
const canary = "CANARY_PRIVATE_INPUT"
failed, diagnostics := parseSource(Bytes("unsafe\nlabel", []byte("name: ["+canary+"\n")), "source-1", limits)
if failed.document != nil || failed.source == nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
t.Fatalf("failed parse = %#v, diagnostics = %#v", failed, diagnostics)
}
var output strings.Builder
if err := WriteDiagnostics(&output, diagnostics); err != nil {
t.Fatal(err)
}
if strings.Contains(output.String(), canary) || strings.Count(output.String(), "\n") != 1 {
t.Fatalf("unsafe rendered diagnostic = %q", output.String())
}
limits.document = yaml.Limits{Depth: 1, Nodes: 20, ParserDepth: 20}
_, diagnostics = parseSource(Bytes("deep", []byte("a:\n b: value\n")), "source-1", limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLDepthLimit || diagnostics[0].Primary.Line == 0 {
t.Fatalf("depth diagnostics = %#v", diagnostics)
}
}

148
path.go Normal file
View File

@@ -0,0 +1,148 @@
package configkit
import (
"errors"
"strconv"
"strings"
)
// Path identifies a configuration value using YAML field names. Its textual form
// is a JSON Pointer: the root is empty, ~ escapes as ~0, and / escapes as ~1.
// Paths are semantic rather than filesystem or URL paths: empty, dot, dot-dot,
// percent-containing, and numeric keys retain their literal meaning. Parsed
// numeric tokens are resolved only when traversal knows their containing type.
//
// Paths are immutable. The zero value is the root path.
type Path struct {
segments []pathSegment
invalid bool
}
type pathSegmentKind uint8
const (
pathSegmentKey pathSegmentKind = iota
pathSegmentIndex
pathSegmentParsed
)
type pathSegment struct {
kind pathSegmentKind
key string
index int
}
// Keys constructs a path of mapping keys. Keys with no arguments returns the
// root path.
func Keys(keys ...string) Path {
p := Path{}
for _, key := range keys {
p = p.Key(key)
}
return p
}
// ParsePath parses a JSON Pointer. Numeric tokens remain unresolved until schema
// or document traversal determines whether the containing value is a mapping or
// sequence.
func ParsePath(pointer string) (Path, error) {
if pointer == "" {
return Path{}, nil
}
if pointer[0] != '/' {
return Path{}, errors.New("configkit: path must be empty or begin with slash")
}
parts := strings.Split(pointer[1:], "/")
segments := make([]pathSegment, 0, len(parts))
for _, part := range parts {
key, ok := unescapePathToken(part)
if !ok {
return Path{}, errors.New("configkit: path contains an invalid tilde escape")
}
segments = append(segments, pathSegment{kind: pathSegmentParsed, key: key})
}
return Path{segments: segments}, nil
}
// Key returns a new path with key appended as a mapping-key segment.
func (p Path) Key(key string) Path {
segments := clonePathSegments(p.segments, 1)
segments = append(segments, pathSegment{kind: pathSegmentKey, key: key})
return Path{segments: segments, invalid: p.invalid}
}
// Index returns a new path with index appended as a sequence-index segment.
// A negative index produces an invalid path that configuration operations reject.
func (p Path) Index(index int) Path {
segments := clonePathSegments(p.segments, 1)
segments = append(segments, pathSegment{kind: pathSegmentIndex, index: index})
return Path{segments: segments, invalid: p.invalid || index < 0}
}
// String returns the path's canonical JSON Pointer spelling.
func (p Path) String() string {
if len(p.segments) == 0 {
return ""
}
var b strings.Builder
for _, segment := range p.segments {
b.WriteByte('/')
if segment.kind == pathSegmentIndex {
b.WriteString(strconv.Itoa(segment.index))
continue
}
b.WriteString(escapePathToken(segment.key))
}
return b.String()
}
func (p Path) clone() Path {
return Path{segments: clonePathSegments(p.segments, 0), invalid: p.invalid}
}
func (p Path) valid() bool {
return !p.invalid
}
func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
if len(segments) == 0 && extra == 0 {
return nil
}
cloned := make([]pathSegment, len(segments), len(segments)+extra)
copy(cloned, segments)
return cloned
}
func escapePathToken(token string) string {
token = strings.ReplaceAll(token, "~", "~0")
return strings.ReplaceAll(token, "/", "~1")
}
func unescapePathToken(token string) (string, bool) {
if !strings.Contains(token, "~") {
return token, true
}
var b strings.Builder
for i := 0; i < len(token); i++ {
if token[i] != '~' {
b.WriteByte(token[i])
continue
}
if i+1 >= len(token) {
return "", false
}
i++
switch token[i] {
case '0':
b.WriteByte('~')
case '1':
b.WriteByte('/')
default:
return "", false
}
}
return b.String(), true
}

27
path_fuzz_test.go Normal file
View File

@@ -0,0 +1,27 @@
package configkit
import "testing"
func FuzzParsePath(f *testing.F) {
for _, seed := range []string{"", "/", "/service/port", "/a~1b/~0key", "/0", "relative", "/bad~2escape"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, input string) {
if len(input) > 4096 {
return
}
path, err := ParsePath(input)
if err != nil {
return
}
canonical := path.String()
reparsed, err := ParsePath(canonical)
if err != nil {
t.Fatalf("canonical path %q did not parse: %v", canonical, err)
}
if reparsed.String() != canonical {
t.Fatalf("path round trip = %q, want %q", reparsed.String(), canonical)
}
})
}

88
path_test.go Normal file
View File

@@ -0,0 +1,88 @@
package configkit
import "testing"
func TestPathRoundTripsSemanticKeys(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pointer string
}{
{name: "root", pointer: ""},
{name: "slash and tilde", pointer: "/a~1b/c~0d"},
{name: "empty key", pointer: "/"},
{name: "dotted key", pointer: "/outputs/a.b"},
{name: "numeric key", pointer: "/outputs/0"},
{name: "dot keys", pointer: "/./.."},
{name: "percent text", pointer: "/%2F/%7E"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parsed, err := ParsePath(test.pointer)
if err != nil {
t.Fatalf("ParsePath() error = %v", err)
}
if got := parsed.String(); got != test.pointer {
t.Fatalf("String() = %q, want %q", got, test.pointer)
}
})
}
if got := Keys("a/b", "c~d", "", "..").String(); got != "/a~1b/c~0d//.." {
t.Fatalf("Keys().String() = %q", got)
}
if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" {
t.Fatalf("indexed path = %q", got)
}
}
func TestPathPreservesSegmentIntentAndImmutability(t *testing.T) {
t.Parallel()
key := Keys("items", "0")
index := Keys("items").Index(0)
parsed, err := ParsePath("/items/0")
if err != nil {
t.Fatal(err)
}
if key.String() != index.String() || index.String() != parsed.String() {
t.Fatal("equivalent textual paths should share a JSON Pointer spelling")
}
if key.segments[1].kind != pathSegmentKey {
t.Fatal("Keys numeric segment lost mapping-key intent")
}
if index.segments[1].kind != pathSegmentIndex {
t.Fatal("Index segment lost sequence intent")
}
if parsed.segments[1].kind != pathSegmentParsed {
t.Fatal("parsed numeric segment was resolved before traversal")
}
base := Keys("parent")
child := base.Key("child")
child.segments[0].key = "changed"
if got := base.String(); got != "/parent" {
t.Fatalf("builder mutated parent: %q", got)
}
negative := Path{}.Index(-1)
if negative.valid() {
t.Fatal("negative index must produce an invalid path")
}
}
func TestParsePathRejectsMalformedPointers(t *testing.T) {
t.Parallel()
for _, pointer := range []string{"relative", "~0", "/bad~", "/bad~2", "/bad~01~"} {
if _, err := ParsePath(pointer); err == nil {
t.Errorf("ParsePath(%q) succeeded", pointer)
}
}
if parsed, err := ParsePath("/missing"); err != nil || !parsed.valid() {
t.Fatalf("valid missing path rejected: path=%v error=%v", parsed, err)
}
}

179
profile.go Normal file
View File

@@ -0,0 +1,179 @@
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"
)
func activeProfile(name string) bool {
return name != "" && name != "default"
}
func validProfileName(name string) bool {
if !activeProfile(name) {
return true
}
for _, character := range name {
if (character >= 'a' && character <= 'z') ||
(character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '_' || character == '-' {
continue
}
return false
}
return true
}
func loadProfileDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return nil, cloneSourceInfos(sources), []Diagnostic{*diagnostic}
}
profilePath := filepath.Join(rootPath, "profiles", name+".yml")
canonical, err := canonicalExistingPath(profilePath)
if err != nil {
code := CodeProfileInvalid
message := "selected profile could not be resolved"
if errors.Is(err, fs.ErrNotExist) {
code, message = CodeProfileMissing, "selected profile does not exist"
} else if errors.Is(err, fs.ErrPermission) {
code, message = CodeImportPermission, "selected profile cannot be accessed"
}
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
}
if !containedPath(rootPath, canonical) {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile resolves outside the configuration root", nil)}
}
for _, existing := range sources {
if existing.Path != "" && existing.Path == canonical {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileRepeated, "selected profile is already present in the base source graph", nil)}
}
}
if len(sources)+1 > limits.sources {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the source-count limit", nil)}
}
root, err := os.OpenRoot(rootPath)
if err != nil {
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected profile", Location{})
value.Primary = nil
return nil, cloneSourceInfos(sources), []Diagnostic{value}
}
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)}
}
sourceID := "source-" + strconv.Itoa(len(sources)+1)
profileLocation := &Location{SourceID: sourceID}
profileSource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("profiles", name+".yml")), Path: canonical, Layer: LayerProfile}
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)
}
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 {
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
}
profiled, diagnostics := overlayDocuments(base, profileRoot)
if profiled != nil {
profiled.inputBytes = base.inputBytes + int64(len(data))
profiled.inputNodes = base.inputNodes + nodeCount
}
return profiled, sources, diagnostics
}
func readProfileFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
limit := min(int64(maxSourceBytes), remaining)
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})
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
}

267
profile_test.go Normal file
View File

@@ -0,0 +1,267 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
type profileTestConfig struct {
Settings struct {
Kept string `yaml:"kept"`
Changed string `yaml:"changed"`
Added string `yaml:"added"`
} `yaml:"settings"`
Values []string `yaml:"values"`
Nullable *string `yaml:"nullable"`
Value any `yaml:"value"`
Schema map[string]string `yaml:"schema"`
}
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
imports: [{path: base.yml}]
settings:
kept: main
changed: base
values: [base, stale]
nullable: base
value: {child: base}
`)
basePath := writeImportFile(t, directory, "base.yml", "settings: {added: imported}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `settings:
changed: profile
values: [profile]
nullable: null
value: scalar
`)
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{
Imports: true,
Profile: "testing",
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
defaultValue := "default"
return profileTestConfig{
Values: []string{"default"}, Nullable: &defaultValue,
Settings: struct {
Kept string `yaml:"kept"`
Changed string `yaml:"changed"`
Added string `yaml:"added"`
}{Kept: "default", Changed: "default", Added: "default"},
}
}},
})
if err != nil {
t.Fatal(err)
}
if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "profile" || result.Config.Settings.Added != "imported" {
t.Fatalf("settings = %#v", result.Config.Settings)
}
if !reflect.DeepEqual(result.Config.Values, []string{"profile"}) || result.Config.Nullable != nil || result.Config.Value != "scalar" {
t.Fatalf("profile values = values %#v nullable %#v value %#v", result.Config.Values, result.Config.Nullable, result.Config.Value)
}
sources := result.Sources()
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, profilePath)}
gotPaths := make([]string, len(sources))
for index, source := range sources {
gotPaths[index] = source.Path
}
if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
changed, _ := result.Metadata().Lookup(Keys("settings", "changed"))
kept, _ := result.Metadata().Lookup(Keys("settings", "kept"))
if changed.Origin.Layer != LayerProfile || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase {
t.Fatalf("origins = changed %#v kept %#v", changed, kept)
}
if _, ok := result.Metadata().Lookup(Keys("value", "child")); ok {
t.Fatal("removed child remains effective")
}
childHistory := result.Metadata().History(Keys("value", "child"))
if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerProfile {
t.Fatalf("removed child history = %#v", childHistory)
}
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
t.Fatal("removed sequence index remains effective")
}
indexHistory := result.Metadata().History(Keys("values").Index(1))
if len(indexHistory) < 2 || indexHistory[len(indexHistory)-1].Path.String() != "/values" {
t.Fatalf("removed sequence history = %#v", indexHistory)
}
}
func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
for _, name := range []string{"", "default"} {
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
}
}
selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
t.Fatalf("selected profile = %#v, %v", selected, err)
}
for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} {
t.Run(name, func(t *testing.T) {
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: name}, CodeOptionsInvalid)
})
}
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "missing"}, CodeProfileMissing)
writeImportFile(t, directory, "profiles/fallback.yaml", "settings: {changed: fallback}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "fallback"}, CodeProfileMissing)
}
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
if !version.Present || version.Text != "v1" {
return errors.New("unexpected version")
}
return nil
}}
for _, test := range []struct {
name string
profile string
code Code
}{
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
})
}
t.Run("mapping sibling is allowed", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
if err != nil || result.Config.Schema["other"] != "allowed" {
t.Fatalf("mapping sibling = %#v, %v", result, err)
}
})
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")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
})
}
func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
t.Run("base parse precedes profile", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "settings: [\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: profile}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
})
t.Run("profile parse", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: [\n")
loadError := requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
if loadError.Diagnostics()[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
t.Fatalf("profile parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
}
})
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
t.Run("symlink escape", func(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsideProfile := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n")
if err := os.Mkdir(filepath.Join(root, "profiles"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideProfile, filepath.Join(root, "profiles/testing.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, root, "main.yml", "{}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeProfileOutsideRoot)
})
}
t.Run("byte source context", func(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
})
if err != nil || result.Config.Settings.Changed != "selected" {
t.Fatalf("byte profile = %#v, %v", result, err)
}
requireProfileLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[profileTestConfig]{Profile: "testing"}, CodeOptionsInvalid)
})
}
func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
for _, test := range []struct {
name string
limits func(parsedSource, int) importLimits
}{
{name: "source count", limits: func(_ parsedSource, _ int) importLimits {
return importLimits{sources: 1, bytes: maxTotalBytes, nodes: maxDocumentNodes}
}},
{name: "aggregate bytes", limits: func(main parsedSource, profileBytes int) importLimits {
return importLimits{sources: 2, bytes: main.bytes + int64(profileBytes) - 1, nodes: maxDocumentNodes}
}},
{name: "aggregate nodes", limits: func(main parsedSource, _ int) importLimits {
return importLimits{sources: 2, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}
}},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
profileData := "settings: {changed: selected}\n"
writeImportFile(t, directory, "profiles/testing.yml", profileData)
main, diagnostics := parseSource(File(mainPath), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base, diagnostics := composeDocuments(main.document)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base.inputBytes = main.bytes
base.inputNodes = countInputNodes(main.document)
_, _, diagnostics = loadProfileDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(profileData)))
if len(diagnostics) == 0 || diagnostics[0].Code != CodeProfileLimitExceeded {
t.Fatalf("diagnostics = %#v", diagnostics)
}
})
}
}
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
t.Helper()
result, err := Load[profileTestConfig](source, options)
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v, want %s", diagnostics, code)
}
return loadError
}

605
projection.go Normal file
View File

@@ -0,0 +1,605 @@
package configkit
import (
"encoding"
"math"
"reflect"
"slices"
"strconv"
"time"
"unicode/utf8"
"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"}
}
operation := valueOperation{schema: schema, limits: limits, active: make(map[valueVisit]bool)}
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
}
if descriptor.kind == descriptorPointer {
if value.IsNil() {
return o.node(document.Null, path, depth)
}
leave, issue := o.enter(value, path)
if issue != nil {
return nil, issue
}
defer leave()
return o.project(value.Elem(), descriptor.element, path, depth)
}
switch descriptor.kind {
case descriptorStruct:
result, issue := o.node(document.Mapping, path, depth)
if issue != nil {
return nil, issue
}
result.Mapping = make([]document.Entry, 0, len(descriptor.fields))
seen := make(map[string]bool, len(descriptor.fields))
for _, member := range descriptor.members {
memberValue := value.Field(member.index)
if member.inline {
if isNilValue(memberValue) {
continue
}
inline, issue := o.project(memberValue, member.descriptor, path, depth)
if issue != nil {
return nil, issue
}
if inline.Kind != document.Mapping {
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "inline value did not project to a mapping"}
}
o.nodes-- // The flattened container is not retained in the output tree.
for _, entry := range inline.Mapping {
if seen[entry.Key] {
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: path.Key(entry.Key), message: "inline value conflicts with a declared field"}
}
seen[entry.Key] = true
result.Mapping = append(result.Mapping, entry)
}
continue
}
childPath := path.Key(member.name)
if seen[member.name] {
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: childPath, message: "inline value conflicts with a declared field"}
}
if issue := o.charge(childPath); issue != nil { // mapping key
return nil, issue
}
child, issue := o.project(memberValue, member.descriptor, childPath, depth+1)
if issue != nil {
return nil, issue
}
seen[member.name] = true
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
}
return result, nil
case descriptorMap:
if value.IsNil() {
return o.node(document.Null, path, depth)
}
leave, issue := o.enter(value, path)
if issue != nil {
return nil, issue
}
defer leave()
result, issue := o.node(document.Mapping, path, depth)
if issue != nil {
return nil, issue
}
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())
})
for _, key := range keys {
childPath := path.Key(key.String())
if issue := o.charge(childPath); issue != nil {
return nil, issue
}
child, issue := o.project(value.MapIndex(key), descriptor.element, childPath, depth+1)
if issue != nil {
return nil, issue
}
result.Mapping = append(result.Mapping, document.Entry{Key: key.String(), Value: child})
}
return result, nil
case descriptorSlice:
if value.IsNil() {
return o.node(document.Null, path, depth)
}
leave, issue := o.enter(value, path)
if issue != nil {
return nil, issue
}
defer leave()
fallthrough
case descriptorArray:
result, issue := o.node(document.Sequence, path, depth)
if issue != nil {
return nil, issue
}
result.Sequence = make([]*document.Node, 0, value.Len())
for i := 0; i < value.Len(); i++ {
childPath := path.Index(i)
child, issue := o.project(value.Index(i), descriptor.element, childPath, depth+1)
if issue != nil {
return nil, issue
}
result.Sequence = append(result.Sequence, child)
}
return result, nil
case descriptorDynamic:
if value.IsNil() {
return o.node(document.Null, path, depth)
}
concrete := value.Elem()
dynamic, issue := o.dynamicDescriptor(concrete.Type(), path)
if issue != nil {
return nil, issue
}
return o.project(concrete, dynamic, path, depth)
case descriptorString:
node, issue := o.node(document.String, path, depth)
if issue == nil {
node.Text = value.String()
}
return node, issue
case descriptorBoolean:
node, issue := o.node(document.Boolean, path, depth)
if issue == nil {
node.Text = strconv.FormatBool(value.Bool())
}
return node, issue
case descriptorSigned:
node, issue := o.node(document.Number, path, depth)
if issue == nil {
node.Text = strconv.FormatInt(value.Int(), 10)
}
return node, issue
case descriptorUnsigned:
node, issue := o.node(document.Number, path, depth)
if issue == nil {
node.Text = strconv.FormatUint(value.Uint(), 10)
}
return node, issue
case descriptorFloat:
if math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
return nil, &projectionIssue{code: CodeSchemaNonFinite, path: path.clone(), message: "configuration float must be finite"}
}
node, issue := o.node(document.Number, path, depth)
if issue == nil {
node.Text = strconv.FormatFloat(value.Float(), 'g', -1, value.Type().Bits())
}
return node, issue
case descriptorDuration:
node, issue := o.node(document.String, path, depth)
if issue == nil {
node.Text = time.Duration(value.Int()).String()
}
return node, issue
case descriptorSecret:
node, issue := o.node(document.String, path, depth)
if issue == nil {
node.Text = value.Interface().(Secret).EnvName()
}
return node, issue
case descriptorText:
text, issue := marshalCanonicalText(value, path)
if issue != nil {
return nil, issue
}
node, nodeIssue := o.node(document.String, path, depth)
if nodeIssue == nil {
node.Text = text
}
return node, nodeIssue
default:
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "configuration type is not supported"}
}
}
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
}
if issue := o.charge(path); issue != nil {
return nil, issue
}
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() {
marshaler, _ = value.Interface().(encoding.TextMarshaler)
}
if marshaler == nil {
addressable := reflect.New(value.Type())
addressable.Elem().Set(value)
marshaler, _ = addressable.Interface().(encoding.TextMarshaler)
}
if marshaler == nil {
return "", &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar has no usable text marshaler"}
}
text, err := marshaler.MarshalText()
if err != nil || !utf8.Valid(text) {
return "", &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar could not be projected"}
}
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
}

394
projection_test.go Normal file
View File

@@ -0,0 +1,394 @@
package configkit
import (
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type mutableText struct {
data []byte
}
func (value mutableText) MarshalText() ([]byte, error) {
return value.data, nil
}
func (value *mutableText) UnmarshalText(text []byte) error {
value.data = append(value.data[:0], text...)
return nil
}
type failingText struct {
failUnmarshal bool
}
func (value failingText) MarshalText() ([]byte, error) {
return []byte("CANARY_CODEC_TEXT"), errors.New("CANARY_MARSHAL_ERROR")
}
func (value *failingText) UnmarshalText([]byte) error {
if value.failUnmarshal {
return errors.New("CANARY_UNMARSHAL_ERROR")
}
return nil
}
type reconstructFailText string
func (value reconstructFailText) MarshalText() ([]byte, error) {
return []byte(value), nil
}
func (value *reconstructFailText) UnmarshalText([]byte) error {
return errors.New("CANARY_RECONSTRUCT_ERROR")
}
func TestProjectionPreservesCanonicalTypesAndNilEmptyDistinctions(t *testing.T) {
t.Parallel()
type nested struct {
Value string `yaml:"value"`
}
type config struct {
Zero int `yaml:"zero"`
Wide uint64 `yaml:"wide"`
Delay time.Duration `yaml:"delay"`
Pointer *nested `yaml:"pointer"`
NilPointer *nested `yaml:"nil_pointer"`
EmptyMap map[string]string `yaml:"empty_map"`
NilMap map[string]string `yaml:"nil_map"`
EmptySlice []string `yaml:"empty_slice"`
NilSlice []string `yaml:"nil_slice"`
Objects map[string]*nested `yaml:"objects"`
Fixed [2]int `yaml:"fixed"`
Custom mutableText `yaml:"custom"`
Dynamic any `yaml:"dynamic"`
Ignored *nested `yaml:"-"`
private *nested
}
value := config{
Wide: math.MaxUint64,
Delay: 1500 * time.Millisecond,
Pointer: &nested{Value: "pointer"},
EmptyMap: map[string]string{},
EmptySlice: []string{},
Objects: map[string]*nested{
"z": {Value: "last"},
"a": {Value: "first"},
},
Fixed: [2]int{1, 2},
Custom: mutableText{data: []byte("canonical")},
Dynamic: map[string]any{"count": int64(9), "items": []any{"x", false}},
Ignored: &nested{Value: "ignored"},
private: &nested{Value: "private"},
}
schema := mustSchemaFor[config](t)
projected, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatalf("projectConfiguration() issue = %#v", issue)
}
assertDocument(t, mappingValue(t, projected, "zero"), document.Number, "0")
assertDocument(t, mappingValue(t, projected, "wide"), document.Number, "18446744073709551615")
assertDocument(t, mappingValue(t, projected, "delay"), document.String, "1.5s")
assertDocument(t, mappingValue(t, projected, "custom"), document.String, "canonical")
if mappingValue(t, projected, "nil_pointer").Kind != document.Null || mappingValue(t, projected, "nil_map").Kind != document.Null || mappingValue(t, projected, "nil_slice").Kind != document.Null {
t.Fatal("nil values did not project as null")
}
if empty := mappingValue(t, projected, "empty_map"); empty.Kind != document.Mapping || empty.Mapping == nil || len(empty.Mapping) != 0 {
t.Fatalf("empty map = %#v", empty)
}
if empty := mappingValue(t, projected, "empty_slice"); empty.Kind != document.Sequence || empty.Sequence == nil || len(empty.Sequence) != 0 {
t.Fatalf("empty slice = %#v", empty)
}
objects := mappingValue(t, projected, "objects")
if len(objects.Mapping) != 2 || objects.Mapping[0].Key != "a" || objects.Mapping[1].Key != "z" {
t.Fatalf("map order = %#v", objects.Mapping)
}
if hasMappingKey(projected, "ignored") || hasMappingKey(projected, "private") {
t.Fatal("ignored field entered projection")
}
clonedValue, cloneIssue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if cloneIssue != nil {
t.Fatal(cloneIssue.message)
}
cloned := clonedValue.Interface().(config)
if cloned.NilMap != nil || cloned.NilSlice != nil || cloned.NilPointer != nil || cloned.EmptyMap == nil || cloned.EmptySlice == nil {
t.Fatalf("clone lost nil/empty distinctions: %#v", cloned)
}
}
func TestCloneConfigurationDetachesAllSupportedMutableValues(t *testing.T) {
t.Parallel()
type nested struct {
Values []string `yaml:"values"`
}
type config struct {
First *nested `yaml:"first"`
Second *nested `yaml:"second"`
Map map[string]*nested `yaml:"map"`
Slice []map[string]int `yaml:"slice"`
Custom mutableText `yaml:"custom"`
Dynamic any `yaml:"dynamic"`
Ignored *nested `yaml:"-"`
}
shared := &nested{Values: []string{"original"}}
value := config{
First: shared,
Second: shared,
Map: map[string]*nested{"entry": {Values: []string{"map"}}},
Slice: []map[string]int{{"count": 1}},
Custom: mutableText{data: []byte("text")},
Dynamic: map[string]any{
"items": []any{map[string]any{"name": "dynamic"}},
},
Ignored: &nested{Values: []string{"ignored"}},
}
schema := mustSchemaFor[config](t)
clonedValue, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatalf("cloneConfiguration() issue = %#v", issue)
}
cloned := clonedValue.Interface().(config)
if cloned.First == cloned.Second || cloned.First == shared || cloned.Custom.data == nil || &cloned.Custom.data[0] == &value.Custom.data[0] {
t.Fatal("clone retained mutable identity")
}
if cloned.Ignored != nil {
t.Fatal("ignored field was retained")
}
value.First.Values[0] = "changed"
value.Map["entry"].Values[0] = "changed"
value.Slice[0]["count"] = 2
value.Custom.data[0] = 'X'
value.Dynamic.(map[string]any)["items"].([]any)[0].(map[string]any)["name"] = "changed"
if cloned.First.Values[0] != "original" || cloned.Map["entry"].Values[0] != "map" || cloned.Slice[0]["count"] != 1 || string(cloned.Custom.data) != "text" || cloned.Dynamic.(map[string]any)["items"].([]any)[0].(map[string]any)["name"] != "dynamic" {
t.Fatalf("clone changed after source mutation: %#v", cloned)
}
}
func TestProjectionAndCloneRejectCyclesAndInvalidDynamicValues(t *testing.T) {
t.Parallel()
type recursive struct {
Next *recursive `yaml:"next"`
}
type config struct {
Tree *recursive `yaml:"tree"`
Dynamic any `yaml:"dynamic"`
}
cyclic := &recursive{}
cyclic.Next = cyclic
schema := mustSchemaFor[config](t)
acyclic := config{Tree: &recursive{Next: &recursive{}}}
if _, issue := projectConfiguration(schema, reflect.ValueOf(acyclic), defaultProjectionLimits()); issue != nil {
t.Fatalf("acyclic recursive projection issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(acyclic), defaultProjectionLimits()); issue != nil {
t.Fatalf("acyclic recursive clone issue = %#v", issue)
}
value := config{Tree: cyclic}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle || issue.path.String() != "/tree/next" {
t.Fatalf("projection cycle issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
t.Fatalf("clone cycle issue = %#v", issue)
}
dynamicCycle := map[string]any{}
dynamicCycle["self"] = dynamicCycle
value = config{Dynamic: dynamicCycle}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
t.Fatalf("dynamic cycle issue = %#v", issue)
}
value = config{Dynamic: make(chan int)}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic type issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic clone issue = %#v", issue)
}
}
func TestProjectionRejectsNonFiniteLimitsAndCodecFailuresSafely(t *testing.T) {
t.Parallel()
type numbers struct {
Value float64 `yaml:"value"`
}
numberSchema := mustSchemaFor[numbers](t)
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
if _, issue := projectConfiguration(numberSchema, reflect.ValueOf(numbers{Value: value}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaNonFinite {
t.Fatalf("non-finite issue = %#v", issue)
}
}
type codecConfig struct {
Value failingText `yaml:"value"`
}
codecSchema := mustSchemaFor[codecConfig](t)
if _, issue := projectConfiguration(codecSchema, reflect.ValueOf(codecConfig{}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCodec || strings.Contains(issue.message, "CANARY") {
t.Fatalf("codec projection issue = %#v", issue)
}
type reconstructConfig struct {
Value reconstructFailText `yaml:"value"`
}
reconstructSchema := mustSchemaFor[reconstructConfig](t)
if _, issue := cloneConfiguration(reconstructSchema, reflect.ValueOf(reconstructConfig{Value: "safe-name"}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCodec || strings.Contains(issue.message, "CANARY") {
t.Fatalf("codec clone issue = %#v", issue)
}
type simple struct {
Value string `yaml:"value"`
}
simpleSchema := mustSchemaFor[simple](t)
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 1, nodes: 3}); issue != nil {
t.Fatalf("exact node limit rejected: %#v", issue)
}
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 1, nodes: 2}); issue == nil || issue.code != CodeSchemaProjectionLimit {
t.Fatalf("node overflow issue = %#v", issue)
}
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 0, nodes: 3}); issue == nil || issue.code != CodeSchemaProjectionLimit {
t.Fatalf("depth overflow issue = %#v", issue)
}
}
func TestInlineProjectionAndCloneUsePhysicalMembers(t *testing.T) {
t.Parallel()
type Embedded struct {
Name string `yaml:"name"`
Items map[string]string `yaml:"items"`
}
type config struct {
*Embedded `yaml:",inline"`
Extras map[string]any `yaml:",inline"`
}
value := config{
Embedded: &Embedded{Name: "value", Items: map[string]string{"a": "b"}},
Extras: map[string]any{"extra": []string{"one"}},
}
schema := mustSchemaFor[config](t)
projected, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil || !hasMappingKey(projected, "name") || !hasMappingKey(projected, "items") || !hasMappingKey(projected, "extra") {
t.Fatalf("inline projection = %#v, issue=%#v", projected, issue)
}
clonedValue, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
cloned := clonedValue.Interface().(config)
value.Items["a"] = "changed"
value.Extras["extra"].([]string)[0] = "changed"
if cloned.Items["a"] != "b" || cloned.Extras["extra"].([]string)[0] != "one" {
t.Fatal("inline clone retained aliases")
}
value.Extras["name"] = "collision"
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaAmbiguousField || issue.path.String() != "/name" {
t.Fatalf("inline collision issue = %#v", issue)
}
}
func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
t.Parallel()
type nested struct {
Value string `yaml:"value"`
}
type config struct {
Nested nested `yaml:"nested"`
Mapping map[string]int `yaml:"mapping"`
Sequence []string `yaml:"sequence"`
Maybe map[string]int `yaml:"maybe"`
}
schema := mustSchemaFor[config](t)
before, issue := projectConfiguration(schema, reflect.ValueOf(config{
Nested: nested{Value: "before"},
Mapping: map[string]int{"remove": 1, "same": 2},
Sequence: []string{"a", "b"},
}), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
after, issue := projectConfiguration(schema, reflect.ValueOf(config{
Nested: nested{Value: "after"},
Mapping: map[string]int{"add": 3, "same": 2},
Sequence: []string{"a", "changed"},
Maybe: map[string]int{},
}), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
deltas := compareDocumentTrees(before, after)
want := []struct {
path string
kind documentDeltaKind
}{
{path: "/mapping/add", kind: documentAdded},
{path: "/mapping/remove", kind: documentRemoved},
{path: "/maybe", kind: documentReplaced},
{path: "/nested/value", kind: documentReplaced},
{path: "/sequence", kind: documentReplaced},
}
if len(deltas) != len(want) {
t.Fatalf("deltas = %#v", deltas)
}
for i := range want {
if deltas[i].path.String() != want[i].path || deltas[i].kind != want[i].kind {
t.Fatalf("delta[%d] = %#v, want %#v", i, deltas[i], want[i])
}
}
for _, delta := range deltas {
if strings.HasPrefix(delta.path.String(), "/sequence/") {
t.Fatal("sequence comparison created index correspondence")
}
}
if equal := compareDocumentTrees(after, document.Clone(after)); len(equal) != 0 {
t.Fatalf("equal trees changed = %#v", equal)
}
}
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
t.Helper()
schema, issue := buildSchema(reflect.TypeFor[T]())
if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue)
}
return schema
}
func mappingValue(t *testing.T, node *document.Node, key string) *document.Node {
t.Helper()
for _, entry := range node.Mapping {
if entry.Key == key {
return entry.Value
}
}
t.Fatalf("mapping key %q missing", key)
return nil
}
func hasMappingKey(node *document.Node, key string) bool {
for _, entry := range node.Mapping {
if entry.Key == key {
return true
}
}
return false
}
func assertDocument(t *testing.T, node *document.Node, kind document.Kind, text string) {
t.Helper()
if node.Kind != kind || node.Text != text {
t.Fatalf("node = %#v, want kind=%v text=%q", node, kind, text)
}
}

218
provenance_test.go Normal file
View File

@@ -0,0 +1,218 @@
package configkit
import (
"errors"
"reflect"
"sync"
"testing"
)
type layeredItem struct {
Value string `yaml:"value"`
}
type layeredConfig struct {
Shared struct {
Equal string `yaml:"equal"`
Imported string `yaml:"imported"`
Profile string `yaml:"profile"`
} `yaml:"shared"`
Values []int `yaml:"values"`
Items map[string]layeredItem `yaml:"items"`
Value any `yaml:"value"`
Nullable *string `yaml:"nullable"`
Derived int `yaml:"derived"`
Empty map[string]string `yaml:"empty"`
}
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
imports: [{path: imported.yml}]
shared: {equal: same}
values: [1, 2]
items:
keep: {value: 'yes'}
remove: {value: 'no'}
value: {child: base}
nullable: base
empty: {}
`)
importPath := writeImportFile(t, directory, "imported.yml", "shared: {imported: base}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `shared: {profile: selected}
values: [3, 4]
value: selected
nullable: null
empty: {}
`)
defaultNullable := "default"
definition := Definition[layeredConfig]{
Defaults: func() layeredConfig {
defaults := layeredConfig{
Values: []int{0, 0}, Value: map[string]any{"child": "default"},
Nullable: &defaultNullable, Empty: map[string]string{"retained": "default"},
}
defaults.Shared.Equal = "same"
return defaults
},
Resolve: func(configuration *layeredConfig, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported"), Keys("shared", "imported"))
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported"))
delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled items")
Default(resolution, Keys("derived"), &configuration.Derived, 7,
"derived from equal", Keys("shared", "equal"), Keys("shared", "equal"))
resolution.Warning(Keys("values"), "values were bounded", Keys("shared", "imported"))
return nil
},
}
result, err := Load[layeredConfig](File(mainPath), Options[layeredConfig]{
Imports: true, Profile: "testing", Definition: definition,
})
if err != nil {
var loadError *LoadError
_ = errors.As(err, &loadError)
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
}
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, importPath), canonicalTestPath(t, profilePath)}
sources := result.Sources()
gotSources := make([]string, len(sources))
for index, source := range sources {
gotSources[index] = source.Path
}
if !reflect.DeepEqual(gotSources, wantSources) || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
if result.Config.Shared.Equal != "same" || result.Config.Shared.Imported != "base" || result.Config.Shared.Profile != "selected" ||
!reflect.DeepEqual(result.Config.Values, []int{5}) || result.Config.Nullable != nil || result.Config.Derived != 7 ||
result.Config.Empty["retained"] != "default" {
t.Fatalf("configuration = %#v", result.Config)
}
metadata := result.Metadata()
equal, _ := metadata.Lookup(Keys("shared", "equal"))
derived, _ := metadata.Lookup(Keys("derived"))
nullable, _ := metadata.Lookup(Keys("nullable"))
empty, _ := metadata.Lookup(Keys("empty"))
firstValue, _ := metadata.Lookup(Keys("values").Index(0))
if !equal.Explicit || equal.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault ||
!nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerProfile ||
!empty.Explicit || empty.Origin.Layer != LayerProfile || len(empty.Origin.Locations) != 2 ||
!firstValue.Explicit || firstValue.Origin.Operation != ChangeNormalization {
t.Fatalf("metadata = equal %#v derived %#v nullable %#v empty %#v value %#v", equal, derived, nullable, empty, firstValue)
}
if _, ok := metadata.Lookup(Keys("value", "child")); ok {
t.Fatal("profile-removed child remains effective")
}
if _, ok := metadata.Lookup(Keys("values").Index(1)); ok {
t.Fatal("resolution-removed sequence index remains effective")
}
if _, ok := metadata.Lookup(Keys("items", "remove", "value")); ok {
t.Fatal("resolution-removed mapping child remains effective")
}
assertHistoryLayersAndPaths(t, metadata.History(Keys("values").Index(1)),
[]Layer{LayerDefault, LayerBase, LayerProfile, LayerResolution},
[]string{"/values/1", "/values/1", "/values", "/values"})
assertHistoryLayersAndPaths(t, metadata.History(Keys("value", "child")),
[]Layer{LayerDefault, LayerBase, LayerProfile},
[]string{"/value/child", "/value/child", "/value"})
removedHistory := metadata.History(Keys("items", "remove", "value"))
assertHistoryLayersAndPaths(t, removedHistory,
[]Layer{LayerBase, LayerResolution},
[]string{"/items/remove/value", "/items"})
if !reflect.DeepEqual(removedHistory[1].Reasons, []string{"removed disabled items"}) {
t.Fatalf("removed history = %#v", removedHistory)
}
valuesHistory := metadata.History(Keys("values").Index(0))
lastValue := valuesHistory[len(valuesHistory)-1]
if !reflect.DeepEqual(lastValue.Reasons, []string{"bounded values"}) || len(lastValue.Dependencies) != 1 || lastValue.Dependencies[0].String() != "/shared/imported" {
t.Fatalf("canonical resolution history = %#v", lastValue)
}
schema, issue := buildSchema(reflect.TypeFor[layeredConfig]())
if issue != nil {
t.Fatal(issue)
}
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
if projectionIssue != nil || !documentNodesEqual(projected, result.final) {
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
}
metadata.history[Keys("values").Index(0).String()][0].Origin.Layer = "mutated"
sources[1].ImportedFrom.SourceID = "mutated"
diagnostics := result.Diagnostics()
diagnostics[0].Related[0].SourceID = "mutated"
result.Config.Values[0] = 99
freshHistory := result.Metadata().History(Keys("values").Index(0))
if freshHistory[0].Origin.Layer == "mutated" || result.Sources()[1].ImportedFrom.SourceID == "mutated" ||
result.Diagnostics()[0].Related[0].SourceID == "mutated" {
t.Fatal("returned inspection records alias private state")
}
snapshotValue, ok := documentAtPath(result.final, Keys("values").Index(0))
if !ok || snapshotValue.Text != "5" {
t.Fatal("caller configuration mutation changed the private final snapshot")
}
}
func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "values: [1, 2]\n")
writeImportFile(t, directory, "profiles/testing.yml", "values: [3, 4]\n")
options := Options[layeredConfig]{Profile: "testing"}
const loads = 16
results := make(chan *Result[layeredConfig], loads)
errors := make(chan error, loads)
var group sync.WaitGroup
for index := 0; index < loads; index++ {
group.Add(1)
go func() {
defer group.Done()
result, err := Load[layeredConfig](File(mainPath), options)
if err != nil {
errors <- err
return
}
results <- result
}()
}
group.Wait()
close(results)
close(errors)
for err := range errors {
t.Fatal(err)
}
var collected []*Result[layeredConfig]
for result := range results {
collected = append(collected, result)
}
if len(collected) != loads {
t.Fatalf("completed loads = %d", len(collected))
}
collected[0].Config.Values[0] = 99
collected[0].metadata.fields[Keys("values").String()] = FieldInfo{}
for _, result := range collected[1:] {
if !reflect.DeepEqual(result.Config.Values, []int{3, 4}) {
t.Fatalf("concurrent result contaminated = %#v", result.Config.Values)
}
if info, ok := result.Metadata().Lookup(Keys("values")); !ok || info.Origin.Layer != LayerProfile {
t.Fatalf("concurrent metadata contaminated = %#v, %v", info, ok)
}
}
}
func assertHistoryLayersAndPaths(t *testing.T, history []Change, layers []Layer, paths []string) {
t.Helper()
if len(history) != len(layers) || len(history) != len(paths) {
t.Fatalf("history = %#v", history)
}
for index, change := range history {
if change.Origin.Layer != layers[index] || change.Path.String() != paths[index] {
t.Fatalf("history[%d] = %#v", index, change)
}
}
}

283
records.go Normal file
View File

@@ -0,0 +1,283 @@
package configkit
// Layer identifies the precedence layer responsible for a configuration value.
type Layer string
const (
LayerDefault Layer = "default"
LayerBase Layer = "base"
LayerProfile Layer = "profile"
LayerCLI Layer = "cli"
LayerResolution Layer = "resolution"
)
// ChangeKind identifies an application resolution operation. The zero value is
// used for input and default origins.
type ChangeKind string
const (
ChangeDerivedDefault ChangeKind = "derived_default"
ChangeNormalization ChangeKind = "normalization"
)
// Severity classifies a diagnostic.
type Severity string
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
)
// Code is a stable, machine-readable diagnostic classification.
type Code string
const (
CodeSourceInvalid Code = "source.invalid"
CodeSourceMissing Code = "source.missing"
CodeSourcePermission Code = "source.permission"
CodeSourceRead Code = "source.read"
CodeSourceNotRegular Code = "source.not_regular"
CodeSourceLimitExceeded Code = "source.limit_exceeded"
CodeSourceReaderUsed Code = "source.reader_used"
CodeYAMLInvalid Code = "yaml.invalid"
CodeYAMLEmpty Code = "yaml.empty"
CodeYAMLMultipleDocuments Code = "yaml.multiple_documents"
CodeYAMLRootType Code = "yaml.root_type"
CodeYAMLDuplicateKey Code = "yaml.duplicate_key"
CodeYAMLTag Code = "yaml.tag"
CodeYAMLAnchor Code = "yaml.anchor"
CodeYAMLAlias Code = "yaml.alias"
CodeYAMLMergeKey Code = "yaml.merge_key"
CodeYAMLScalar Code = "yaml.scalar"
CodeYAMLDepthLimit Code = "yaml.depth_limit"
CodeYAMLNodeLimit Code = "yaml.node_limit"
CodeSchemaRoot Code = "schema.root"
CodeSchemaUnsupportedType Code = "schema.unsupported_type"
CodeSchemaAmbiguousField Code = "schema.ambiguous_field"
CodeSchemaTag Code = "schema.tag"
CodeSchemaTextCodec Code = "schema.text_codec"
CodeSchemaInline Code = "schema.inline"
CodeSchemaCycle Code = "schema.cycle"
CodeSchemaProjectionLimit Code = "schema.projection_limit"
CodeSchemaCodec Code = "schema.codec"
CodeSchemaNonFinite Code = "schema.non_finite"
CodeSchemaDynamicType Code = "schema.dynamic_type"
CodeDecodeType Code = "decode.type"
CodeDecodeNull Code = "decode.null"
CodeDecodeRange Code = "decode.range"
CodeDecodeDuration Code = "decode.duration"
CodeDecodeText Code = "decode.text"
CodeDecodeUnknownField Code = "decode.unknown_field"
CodeDecodeArrayLength Code = "decode.array_length"
CodeOptionsInvalid Code = "options.invalid"
CodeOptionsUnavailable Code = "options.unavailable"
CodeImportDisabled Code = "import.disabled"
CodeImportInvalid Code = "import.invalid"
CodeImportMissing Code = "import.missing"
CodeImportPermission Code = "import.permission"
CodeImportRead Code = "import.read"
CodeImportOutsideRoot Code = "import.outside_root"
CodeImportCycle Code = "import.cycle"
CodeImportRepeated Code = "import.repeated"
CodeImportLimitExceeded Code = "import.limit_exceeded"
CodeImportVersion Code = "import.version"
CodeProfileInvalid Code = "profile.invalid"
CodeProfileMissing Code = "profile.missing"
CodeProfileOutsideRoot Code = "profile.outside_root"
CodeProfileRepeated Code = "profile.repeated"
CodeProfileDirective Code = "profile.directive"
CodeProfileVersion Code = "profile.version"
CodeProfileLimitExceeded Code = "profile.limit_exceeded"
CodeComposeInvalid Code = "compose.invalid"
CodeComposeConflict Code = "compose.conflict"
CodeVersionType Code = "version.type"
CodeVersionError Code = "version.error"
CodeVersionWarning Code = "version.warning"
CodeVersionHookFailed Code = "version.hook_failed"
CodeResolveError Code = "resolve.error"
CodeResolveWarning Code = "resolve.warning"
CodeResolveHookFailed Code = "resolve.hook_failed"
CodeResolveInvalidPath Code = "resolve.invalid_path"
CodeResolveInvalidKind Code = "resolve.invalid_kind"
CodeResolveInvalidRecord Code = "resolve.invalid_record"
CodeResolveInvalidTarget Code = "resolve.invalid_target"
CodeResolveInvalidValue Code = "resolve.invalid_value"
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
CodeValidateError Code = "validate.error"
CodeValidateWarning Code = "validate.warning"
CodeValidateInvalidPath Code = "validate.invalid_path"
CodeValidateMutation Code = "validate.mutation"
CodeSecretsInvalidPath Code = "secrets.invalid_path"
CodeSecretsMissing Code = "secrets.missing"
CodeSecretsPermission Code = "secrets.permission"
CodeSecretsRead Code = "secrets.read"
CodeSecretsNotRegular Code = "secrets.not_regular"
CodeSecretsInvalidName Code = "secrets.invalid_name"
CodeSecretsLimitExceeded Code = "secrets.limit_exceeded"
CodeSecretsInvalidValue Code = "secrets.invalid_value"
CodeSecretsApplyFailed Code = "secrets.apply_failed"
CodeSecretRequired Code = "secret.required"
CodeSecretInvalidName Code = "secret.invalid_name"
CodeSecretUnset Code = "secret.unset"
CodeSecretEmpty Code = "secret.empty"
CodeOverrideInvalidPath Code = "override.invalid_path"
CodeOverrideInvalidLabel Code = "override.invalid_label"
CodeOverrideInvalidValue Code = "override.invalid_value"
CodeOverrideDuplicate Code = "override.duplicate"
CodeOverrideOverlap Code = "override.overlap"
CodeOverrideDirective Code = "override.directive"
CodeOverrideVersion Code = "override.version"
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
)
// Location identifies a position in a configuration source. Zero line and column
// values mean that coordinates are unavailable or synthetic.
type Location struct {
SourceID string
Line int
Column int
}
// SourceInfo describes a source discovered during a load. Path is a canonical
// local path when one exists. ImportedFrom identifies the declaration that
// included this source.
type SourceInfo struct {
ID string
Label string
Path string
Layer Layer
ImportedFrom *Location
}
// Diagnostic is a safe structured problem report. Message and source labels must
// never contain configuration values or other secrets.
type Diagnostic struct {
Code Code
Severity Severity
Path Path
Message string
Primary *Location
Related []Location
}
// Origin describes where an effective definition came from. Multiple locations
// represent a composite origin; resolution origins are synthetic and identify their
// operation instead of a file location.
type Origin struct {
Layer Layer
Locations []Location
Operation ChangeKind
}
// FieldInfo describes an effective configuration path.
type FieldInfo struct {
Explicit bool
Null bool
Origin Origin
}
// Change describes a definition or application-resolution operation. Path can name
// an ancestor operation affecting the queried descendant. Historical values are
// intentionally not exposed by this record.
type Change struct {
Path Path
Origin Origin
Previous []Origin
Reasons []string
Dependencies []Path
}
func cloneLocation(location *Location) *Location {
if location == nil {
return nil
}
cloned := *location
return &cloned
}
func cloneLocations(locations []Location) []Location {
if locations == nil {
return nil
}
return append([]Location(nil), locations...)
}
func cloneSourceInfo(source SourceInfo) SourceInfo {
source.ImportedFrom = cloneLocation(source.ImportedFrom)
return source
}
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
if sources == nil {
return nil
}
cloned := make([]SourceInfo, len(sources))
for i, source := range sources {
cloned[i] = cloneSourceInfo(source)
}
return cloned
}
func cloneOrigin(origin Origin) Origin {
origin.Locations = cloneLocations(origin.Locations)
return origin
}
func cloneFieldInfo(info FieldInfo) FieldInfo {
info.Origin = cloneOrigin(info.Origin)
return info
}
func cloneChange(change Change) Change {
change.Path = change.Path.clone()
change.Origin = cloneOrigin(change.Origin)
if change.Previous != nil {
previous := make([]Origin, len(change.Previous))
for i, origin := range change.Previous {
previous[i] = cloneOrigin(origin)
}
change.Previous = previous
}
change.Reasons = append([]string(nil), change.Reasons...)
if change.Dependencies != nil {
dependencies := make([]Path, len(change.Dependencies))
for i, path := range change.Dependencies {
dependencies[i] = path.clone()
}
change.Dependencies = dependencies
}
return change
}
func cloneDiagnostic(diagnostic Diagnostic) Diagnostic {
diagnostic.Path = diagnostic.Path.clone()
diagnostic.Primary = cloneLocation(diagnostic.Primary)
diagnostic.Related = cloneLocations(diagnostic.Related)
return diagnostic
}
func cloneDiagnostics(diagnostics []Diagnostic) []Diagnostic {
if diagnostics == nil {
return nil
}
cloned := make([]Diagnostic, len(diagnostics))
for i, diagnostic := range diagnostics {
cloned[i] = cloneDiagnostic(diagnostic)
}
return cloned
}

46
render.go Normal file
View File

@@ -0,0 +1,46 @@
package configkit
import (
"errors"
"io"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
var errEffectiveUnavailable = errors.New("configkit: effective configuration is unavailable")
// WriteEffective writes deterministic redacted YAML from the frozen resolved
// snapshot. The output is intended for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output.
func (r *Result[T]) WriteEffective(output io.Writer) error {
if r == nil || r.final == nil || output == nil {
return errEffectiveUnavailable
}
safe := redactedDocument(r.final, r.secrets, Path{})
return yamladapter.Write(output, safe)
}
func redactedDocument(node *document.Node, secret map[string]bool, path Path) *document.Node {
result, _ := safeSnapshotDocument(node, secret, path)
return result
}
func pathIsSecret(secret map[string]bool, path Path) bool {
if secret[""] {
return true
}
pointer := path.String()
for pointer != "" {
if secret[pointer] {
return true
}
position := strings.LastIndexByte(pointer, '/')
if position <= 0 {
break
}
pointer = pointer[:position]
}
return false
}

177
render_test.go Normal file
View File

@@ -0,0 +1,177 @@
package configkit
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"sync/atomic"
"testing"
)
const (
taggedCanary = "TAGGED_CANARY_7c65"
dynamicCanary = "DYNAMIC_CANARY_6a12"
historicalCanary = "HISTORICAL_CANARY_91ef"
derivedACanary = "DERIVED_A_CANARY_4c20"
derivedBCanary = "DERIVED_B_CANARY_831d"
)
var renderTextMarshalCalls atomic.Int64
var renderYAMLMarshalCalls atomic.Int64
type renderText string
func (value renderText) MarshalText() ([]byte, error) {
renderTextMarshalCalls.Add(1)
return []byte(value), nil
}
func (value *renderText) UnmarshalText(text []byte) error {
*value = renderText(text)
return nil
}
type renderCredentials struct {
Token string `yaml:"token"`
Nested []string `yaml:"nested"`
}
type renderConfig struct {
Credentials renderCredentials `yaml:"credentials" configkit:"secret"`
Custom renderText `yaml:"custom"`
DerivedA string `yaml:"derived_a"`
DerivedB string `yaml:"derived_b"`
Dynamic any `yaml:"dynamic"`
EmptyMap map[string]string `yaml:"empty_map"`
EmptySequence []string `yaml:"empty_sequence"`
Labels map[string]string `yaml:"labels"`
NilMap map[string]string `yaml:"nil_map"`
NilSequence []string `yaml:"nil_sequence"`
Sequence []string `yaml:"sequence"`
}
func (renderConfig) MarshalYAML() (any, error) {
renderYAMLMarshalCalls.Add(1)
return map[string]string{"forbidden": "application YAML hook ran"}, nil
}
func TestWriteEffectiveRedactsTagsDynamicHistoryAndDependencyCycles(t *testing.T) {
renderTextMarshalCalls.Store(0)
renderYAMLMarshalCalls.Store(0)
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `credentials:
token: TAGGED_CANARY_7c65
nested: [nested-secret]
custom: custom value
dynamic: {retired: HISTORICAL_CANARY_91ef}
empty_map: {}
empty_sequence: []
labels: {hidden: DYNAMIC_CANARY_6a12, public: visible}
sequence: [second, first]
`)
writeImportFile(t, directory, "profiles/testing.yml", "dynamic: selected\n")
definition := Definition[renderConfig]{
Secret: func(path Path) bool {
return path.String() == "/labels/hidden" || path.String() == "/dynamic/retired"
},
Resolve: func(configuration *renderConfig, resolution *Resolution) error {
Normalize(resolution, Keys("derived_a"), &configuration.DerivedA, derivedACanary,
"derived through cycle", Keys("derived_b"))
Normalize(resolution, Keys("derived_b"), &configuration.DerivedB, derivedBCanary,
"derived from retired secret", Keys("derived_a"), Keys("dynamic"))
resolution.Warning(Keys("derived_a"), "derived output is configured")
return nil
},
}
result, err := Load[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"})
if err != nil {
t.Fatal(err)
}
if result.Config.Credentials.Token != taggedCanary || result.Config.Labels["hidden"] != dynamicCanary ||
result.Config.DerivedA != derivedACanary || result.Config.DerivedB != derivedBCanary {
t.Fatal("typed configuration did not retain trusted values")
}
if !result.secrets["/dynamic/retired"] ||
!result.secrets["/derived_a"] || !result.secrets["/derived_b"] {
t.Fatalf("secret closure = %#v", result.secrets)
}
if renderYAMLMarshalCalls.Load() != 0 {
t.Fatal("loading called an application YAML marshal hook")
}
textCalls := renderTextMarshalCalls.Load()
var output bytes.Buffer
if err := result.WriteEffective(&output); err != nil {
t.Fatal(err)
}
want := "credentials: '[REDACTED]'\ncustom: custom value\nderived_a: '[REDACTED]'\nderived_b: '[REDACTED]'\ndynamic: selected\nempty_map: {}\nempty_sequence: []\nlabels:\n hidden: '[REDACTED]'\n public: visible\nnil_map: null\nnil_sequence: null\nsequence:\n - second\n - first\n"
if output.String() != want {
t.Fatalf("effective output:\n%s\nwant:\n%s", output.String(), want)
}
if renderTextMarshalCalls.Load() != textCalls || renderYAMLMarshalCalls.Load() != 0 {
t.Fatal("rendering called an application marshal hook")
}
for _, canary := range []string{taggedCanary, dynamicCanary, historicalCanary, derivedACanary, derivedBCanary, "nested-secret"} {
if strings.Contains(output.String(), canary) {
t.Fatalf("effective output contains %q", canary)
}
formatted := fmt.Sprintf("%v %#v %v %#v", result.Metadata(), result.Metadata(), result.Diagnostics(), result.Diagnostics())
if strings.Contains(formatted, canary) {
t.Fatalf("metadata formatting contains %q", canary)
}
}
beforeMutation := output.String()
result.Config.Credentials.Token = "MUTATED_SECRET"
result.Config.Labels["hidden"] = "MUTATED_DYNAMIC_SECRET"
result.Config.Sequence[0] = "mutated"
output.Reset()
if err := result.WriteEffective(&output); err != nil || output.String() != beforeMutation {
t.Fatalf("frozen output after caller mutation = %q, %v", output.String(), err)
}
wantWriteError := errors.New("write failed")
if err := result.WriteEffective(errorWriter{err: wantWriteError}); !errors.Is(err, wantWriteError) {
t.Fatalf("writer error = %v", err)
}
if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err)
}
var nilResult *Result[renderConfig]
if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) {
t.Fatalf("nil result error = %v", err)
}
}
func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) {
tests := []struct {
name string
input string
definition Definition[renderConfig]
}{
{name: "parse", input: "credentials: [" + taggedCanary + "\n"},
{name: "decode", input: "credentials: " + taggedCanary + "\n"},
{name: "returned hook error", input: "credentials: {token: " + taggedCanary + "}\n", definition: Definition[renderConfig]{
Resolve: func(*renderConfig, *Resolution) error { return errors.New(taggedCanary) },
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.definition.Secret = func(path Path) bool { return path.String() == "/credentials" }
result, err := Load[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition})
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
formatted := fmt.Sprintf("%v %#v %v %#v %v %#v", err, err, loadError.Diagnostics(), loadError.Diagnostics(), loadError.Sources(), loadError.Sources())
if strings.Contains(formatted, taggedCanary) {
t.Fatalf("failure formatting disclosed a secret: %s", formatted)
}
})
}
}

359
resolution.go Normal file
View File

@@ -0,0 +1,359 @@
package configkit
import (
"reflect"
"slices"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const closedCallbackContextMessage = "configkit: callback context is no longer active"
type resolutionState struct {
active bool
schema *schemaDescriptor
presence map[string]inputPresence
document *document.Node
records map[string]*resolutionRecord
diagnostics []Diagnostic
}
type resolutionRecord struct {
path Path
kind ChangeKind
reasons []string
dependencies []Path
helperExpected *document.Node
}
type resolutionSnapshot struct {
change Change
before *document.Node
after *document.Node
}
type resolutionResult struct {
final *document.Node
deltas []documentDelta
snapshots []resolutionSnapshot
diagnostics []Diagnostic
}
// IsExplicit reports whether surviving input explicitly defined path.
func (r *Resolution) IsExplicit(path Path) bool {
state := r.activeState()
if !state.validPath(path) {
return false
}
_, explicit := state.presence[path.String()]
return explicit
}
// Record declares the intent for a resolver mutation. A container path covers
// mutations to its descendants.
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
state := r.activeState()
state.addRecord(path, kind, reason, dependencies, nil)
}
// Error reports a safe, value-free resolution error at path.
func (r *Resolution) Error(path Path, message string, related ...Path) {
r.activeState().report(SeverityError, path, message, related)
}
// Warning reports a safe, value-free resolution warning at path.
func (r *Resolution) Warning(path Path, message string, related ...Path) {
r.activeState().report(SeverityWarning, path, message, related)
}
// Default assigns and records value only when path was absent from surviving
// input. Static defaults are not considered explicit.
func Default[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
state := resolution.activeState()
descriptor, ok := state.helperDescriptor(path, target)
if !ok {
return
}
if _, explicit := state.presence[path.String()]; explicit {
return
}
*target = value
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
if !ok {
return
}
state.addRecord(path, ChangeDerivedDefault, reason, dependencies, expected)
}
// Normalize assigns and records value regardless of input presence.
func Normalize[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
state := resolution.activeState()
descriptor, ok := state.helperDescriptor(path, target)
if !ok {
return
}
*target = value
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
if !ok {
return
}
state.addRecord(path, ChangeNormalization, reason, dependencies, expected)
}
func (r *Resolution) activeState() *resolutionState {
if r == nil || r.state == nil || !r.state.active {
panic(closedCallbackContextMessage)
}
return r.state
}
func (state *resolutionState) validPath(path Path) bool {
if _, ok := resolveSchemaPath(state.schema, path); ok {
return true
}
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution path is not valid for the configuration schema")
return false
}
func (state *resolutionState) helperDescriptor(path Path, target any) (*typeDescriptor, bool) {
resolved, ok := resolveSchemaPath(state.schema, path)
if !ok {
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution helper path is not valid for the configuration schema")
return nil, false
}
value := reflect.ValueOf(target)
if !value.IsValid() || value.Kind() != reflect.Pointer || value.IsNil() {
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target must be a non-nil pointer")
return nil, false
}
if resolved.descriptor.kind != descriptorDynamic && value.Elem().Type() != resolved.descriptor.typeOf {
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target type does not match its configuration path")
return nil, false
}
return resolved.descriptor, true
}
func (state *resolutionState) projectHelperValue(path Path, descriptor *typeDescriptor, value reflect.Value) (*document.Node, bool) {
if descriptor.kind == descriptorDynamic {
wrapped := reflect.New(descriptor.typeOf).Elem()
if value.IsValid() {
wrapped.Set(value)
}
value = wrapped
}
operation := valueOperation{
schema: state.schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool),
}
node, issue := operation.project(value, descriptor, path, 0)
if issue != nil {
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "resolution helper value is not a valid configuration value")
return nil, false
}
return node, true
}
func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason string, dependencies []Path, expected *document.Node) {
if !state.validPath(path) {
return
}
if kind != ChangeDerivedDefault && kind != ChangeNormalization {
state.addDiagnostic(CodeResolveInvalidKind, path, "resolution record has an invalid change kind")
return
}
if reason == "" {
state.addDiagnostic(CodeResolveInvalidRecord, path, "resolution record requires a safe reason")
return
}
for _, dependency := range dependencies {
if !state.validPath(dependency) {
return
}
}
pointer := path.String()
record := state.records[pointer]
if record == nil {
record = &resolutionRecord{path: path.clone(), kind: kind}
state.records[pointer] = record
}
if kind == ChangeNormalization {
record.kind = ChangeNormalization
}
record.reasons = appendUniqueString(record.reasons, reason)
for _, dependency := range dependencies {
record.dependencies = appendUniquePath(record.dependencies, dependency)
}
if expected != nil {
record.helperExpected = document.Clone(expected)
}
}
func (state *resolutionState) report(severity Severity, path Path, message string, related []Path) {
code := CodeResolveError
if severity == SeverityWarning {
code = CodeResolveWarning
}
if !state.validPath(path) {
return
}
diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocation(state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
for _, relatedPath := range related {
if !state.validPath(relatedPath) {
continue
}
if location, ok := nearestDocumentLocation(state.document, relatedPath); ok {
diagnostic.Related = append(diagnostic.Related, Location{
SourceID: location.SourceID, Line: location.Line, Column: location.Column,
})
}
}
state.diagnostics = append(state.diagnostics, diagnostic)
}
func (state *resolutionState) addDiagnostic(code Code, path Path, message string) {
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocation(state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
state.diagnostics = append(state.diagnostics, diagnostic)
}
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult {
state := &resolutionState{
active: true, schema: schema, presence: decoded.presence, document: input,
records: make(map[string]*resolutionRecord),
}
context := &Resolution{state: state}
err := resolver(configuration, context)
state.active = false
if err != nil && !hasErrors(state.diagnostics) {
state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed")
}
if hasErrors(state.diagnostics) {
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
if issue != nil {
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
deltas := compareDocumentTrees(decoded.final, after)
ordered := orderedResolutionRecords(state.records)
for _, delta := range deltas {
if coveringResolutionRecord(ordered, delta.path) == nil {
state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record")
}
}
for _, record := range ordered {
beforeNode, 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)) {
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)}
}
snapshots := make([]resolutionSnapshot, 0, len(ordered))
for _, record := range ordered {
beforeNode, _ := documentAtPath(decoded.final, record.path)
afterNode, _ := documentAtPath(after, record.path)
snapshots = append(snapshots, resolutionSnapshot{
change: Change{
Path: record.path.clone(),
Origin: Origin{Layer: LayerResolution, Operation: record.kind},
Reasons: append([]string(nil), record.reasons...),
Dependencies: clonePaths(record.dependencies),
},
before: document.Clone(beforeNode), after: document.Clone(afterNode),
})
}
return resolutionResult{
final: after, deltas: deltas, snapshots: snapshots,
diagnostics: canonicalDiagnostics(state.diagnostics),
}
}
func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord {
ordered := make([]*resolutionRecord, 0, len(records))
for _, record := range records {
ordered = append(ordered, record)
}
slices.SortFunc(ordered, func(left, right *resolutionRecord) int {
return strings.Compare(left.path.String(), right.path.String())
})
return ordered
}
func coveringResolutionRecord(records []*resolutionRecord, path Path) *resolutionRecord {
var covering *resolutionRecord
for _, record := range records {
if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) {
covering = record
}
}
return covering
}
func pathContains(container, descendant Path) bool {
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
return false
}
if container.String() == "" {
return true
}
prefix := container.String()
value := descendant.String()
return value == prefix || strings.HasPrefix(value, prefix+"/")
}
func appendUniqueString(values []string, value string) []string {
if !slices.Contains(values, value) {
return append(values, value)
}
return values
}
func appendUniquePath(paths []Path, path Path) []Path {
for _, existing := range paths {
if existing.String() == path.String() {
return paths
}
}
return append(paths, path.clone())
}
func clonePaths(paths []Path) []Path {
if paths == nil {
return nil
}
cloned := make([]Path, len(paths))
for index, path := range paths {
cloned[index] = path.clone()
}
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
}

341
resolution_test.go Normal file
View File

@@ -0,0 +1,341 @@
package configkit
import (
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
)
func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) {
t.Parallel()
type artifact struct {
Output string `yaml:"output"`
Timeout time.Duration `yaml:"timeout"`
}
type config struct {
PollInterval time.Duration `yaml:"poll_interval"`
Timeout time.Duration `yaml:"timeout"`
Artifacts map[string]artifact `yaml:"artifacts"`
}
definition := Definition[config]{
Defaults: func() config {
return config{PollInterval: time.Minute, Timeout: 5 * time.Second}
},
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("timeout"), &configuration.Timeout,
configuration.PollInterval/10, "derived from poll interval", Keys("poll_interval"))
for name, current := range configuration.Artifacts {
path := Keys("artifacts", name)
Default(resolution, path.Key("timeout"), &current.Timeout,
configuration.Timeout, "inherited configuration timeout", Keys("timeout"))
Normalize(resolution, path.Key("output"), &current.Output,
strings.TrimSpace(current.Output), "trimmed surrounding whitespace")
configuration.Artifacts[name] = current
}
resolution.Warning(Keys("timeout"), "using derived timeout", Keys("poll_interval"))
return nil
},
}
input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n"
result, err := Load(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if result.Config.Timeout != 6*time.Second {
t.Fatalf("timeout = %s", result.Config.Timeout)
}
for name, want := range map[string]string{"first": "first", "second": "second"} {
current := result.Config.Artifacts[name]
if current.Timeout != 6*time.Second || current.Output != want {
t.Errorf("artifact %q = %#v", name, current)
}
}
if diagnostics := result.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != CodeResolveWarning || diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
metadata := result.Metadata()
timeout, ok := metadata.Lookup(Keys("timeout"))
if !ok || timeout.Explicit || timeout.Origin.Layer != LayerResolution || timeout.Origin.Operation != ChangeDerivedDefault {
t.Fatalf("timeout metadata = %#v, %v", timeout, ok)
}
output, ok := metadata.Lookup(Keys("artifacts", "first", "output"))
if !ok || !output.Explicit || output.Origin.Operation != ChangeNormalization {
t.Fatalf("output metadata = %#v, %v", output, ok)
}
history := metadata.History(Keys("timeout"))
last := history[len(history)-1]
if last.Origin.Operation != ChangeDerivedDefault || !reflect.DeepEqual(last.Reasons, []string{"derived from poll interval"}) || len(last.Dependencies) != 1 || last.Dependencies[0].String() != "/poll_interval" {
t.Fatalf("timeout history = %#v", history)
}
wantOrder := []string{
"/artifacts/first/output", "/artifacts/first/timeout",
"/artifacts/second/output", "/artifacts/second/timeout", "/timeout",
}
gotOrder := make([]string, len(result.resolution))
for index, snapshot := range result.resolution {
gotOrder[index] = snapshot.change.Path.String()
}
if !reflect.DeepEqual(gotOrder, wantOrder) {
t.Fatalf("resolution order = %#v", gotOrder)
}
}
func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *testing.T) {
t.Parallel()
type config struct {
Optional *int `yaml:"optional"`
Count int `yaml:"count"`
Absent int `yaml:"absent"`
}
five := 5
result, err := Load(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} },
Resolve: func(configuration *config, resolution *Resolution) error {
if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) {
return errors.New("unexpected presence")
}
Default(resolution, Keys("optional"), &configuration.Optional, &five, "would derive optional")
Default(resolution, Keys("count"), &configuration.Count, 9, "would derive count")
Default(resolution, Keys("absent"), &configuration.Absent, 9, "derived absent value")
return nil
},
}})
if err != nil {
var loadError *LoadError
_ = errors.As(err, &loadError)
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
}
if result.Config.Optional != nil || result.Config.Count != 0 || result.Config.Absent != 9 {
t.Fatalf("config = %#v", result.Config)
}
if len(result.Metadata().History(Keys("optional"))) != 2 || len(result.Metadata().History(Keys("count"))) != 2 || len(result.Metadata().History(Keys("absent"))) != 2 {
t.Fatal("resolution history disagrees with explicitness")
}
}
func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
Other int `yaml:"other"`
}
result, err := Load(NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Value: 5} },
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
Default(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", Keys("other"))
Normalize(resolution, Keys("value"), &configuration.Value, 7, "canonicalized value", Keys("other"))
return nil
},
}})
if err != nil {
t.Fatal(err)
}
history := result.Metadata().History(Keys("value"))
if len(history) != 2 {
t.Fatalf("history = %#v", history)
}
change := history[1]
if change.Origin.Operation != ChangeNormalization || !reflect.DeepEqual(change.Reasons, []string{"equal derived intent", "updated derived intent", "canonicalized value"}) || len(change.Dependencies) != 1 {
t.Fatalf("coalesced change = %#v", change)
}
}
func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t *testing.T) {
t.Parallel()
type item struct {
Value string `yaml:"value"`
}
type config struct {
Items map[string]item `yaml:"items"`
}
result, err := Load(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
if _, exists := result.Config.Items["remove"]; exists {
t.Fatal("removed entry remains")
}
if _, ok := result.Metadata().Lookup(Keys("items", "remove")); ok {
t.Fatal("removed entry remains effective metadata")
}
if len(result.resolution) != 1 || result.resolution[0].before == nil || result.resolution[0].after == nil {
t.Fatalf("snapshots = %#v", result.resolution)
}
result.Config.Items["keep"] = item{Value: "changed"}
if mappingValue(t, result.resolution[0].after, "keep").Mapping[0].Value.Text != "yes" {
t.Fatal("caller mutation changed private resolution snapshot")
}
}
func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
t.Parallel()
type config struct {
Values []int `yaml:"values"`
}
result, err := Load(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
info, ok := result.Metadata().Lookup(Keys("values").Index(0))
if !ok || !info.Explicit || info.Origin.Layer != LayerResolution || info.Origin.Operation != ChangeNormalization {
t.Fatalf("element metadata = %#v, %v", info, ok)
}
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
t.Fatal("removed sequence element remains effective")
}
}
func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
tests := []struct {
name string
resolve func(*config, *Resolution) error
code Code
}{
{name: "unrecorded", resolve: func(configuration *config, _ *Resolution) error {
configuration.Value = 2
return nil
}, code: CodeResolveUnrecordedChange},
{name: "unrelated target", resolve: func(_ *config, resolution *Resolution) error {
unrelated := 1
Normalize(resolution, Keys("value"), &unrelated, 2, "changed value")
return nil
}, code: CodeResolveInvalidTarget},
{name: "invalid record kind", resolve: func(_ *config, resolution *Resolution) error {
resolution.Record(Keys("value"), ChangeKind("other"), "invalid kind")
return nil
}, code: CodeResolveInvalidKind},
{name: "invalid path", resolve: func(_ *config, resolution *Resolution) error {
resolution.Record(Keys("unknown"), ChangeNormalization, "unknown field")
return nil
}, code: CodeResolveInvalidPath},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
assertLoadDiagnostic(t, result, err, test.code)
})
}
}
func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *testing.T) {
t.Parallel()
type config struct {
Number float64 `yaml:"number"`
Value int `yaml:"value"`
}
tests := []struct {
name string
resolve func(*config, *Resolution) error
code Code
}{
{name: "invalid helper value", resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("number"), &configuration.Number, math.NaN(), "invalid number")
return nil
}, code: CodeResolveInvalidValue},
{name: "reported error", resolve: func(_ *config, resolution *Resolution) error {
resolution.Error(Keys("value"), "value cannot be resolved")
return errors.New("CANARY_RESOLUTION_ERROR")
}, code: CodeResolveError},
{name: "returned error", resolve: func(_ *config, _ *Resolution) error {
return errors.New("CANARY_RESOLUTION_ERROR")
}, code: CodeResolveHookFailed},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
loadError := assertLoadDiagnostic(t, result, err, test.code)
for _, diagnostic := range loadError.Diagnostics() {
if strings.Contains(diagnostic.Message, "CANARY") {
t.Fatalf("diagnostic exposed returned error: %#v", diagnostic)
}
}
})
}
}
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
var retained *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution
return nil
}}}); err != nil {
t.Fatal(err)
}
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
retained.IsExplicit(Keys("value"))
}
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
t.Parallel()
type config struct {
Version string `yaml:"version"`
}
var retained *Validation
if _, err := Load(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation
return nil
}},
}}); err != nil {
t.Fatal(err)
}
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
retained.Warning(Keys("version"), "too late")
}
func assertLoadDiagnostic[T any](t *testing.T, result *Result[T], err error, code Code) *LoadError {
t.Helper()
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v", diagnostics)
}
return loadError
}

430
schema.go Normal file
View File

@@ -0,0 +1,430 @@
package configkit
import (
"encoding"
"reflect"
"strconv"
"strings"
"time"
)
type descriptorKind uint8
const (
descriptorInvalid descriptorKind = iota
descriptorStruct
descriptorMap
descriptorSlice
descriptorArray
descriptorPointer
descriptorDynamic
descriptorString
descriptorBoolean
descriptorSigned
descriptorUnsigned
descriptorFloat
descriptorDuration
descriptorSecret
descriptorText
)
type schemaDescriptor struct {
root *typeDescriptor
types map[reflect.Type]*typeDescriptor
}
type typeDescriptor struct {
typeOf reflect.Type
kind descriptorKind
element *typeDescriptor
length int
members []structMember
fields []fieldDescriptor
fieldByName map[string]int
inlineMap *fieldDescriptor
}
type structMember struct {
index int
name string
descriptor *typeDescriptor
inline bool
}
type fieldDescriptor struct {
name string
index []int
descriptor *typeDescriptor
secret bool
inlineMap bool
}
type schemaIssue struct {
code Code
path Path
message string
}
var (
durationType = reflect.TypeFor[time.Duration]()
secretType = reflect.TypeFor[Secret]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
)
func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"}
}
builder := schemaBuilder{
cache: make(map[reflect.Type]*typeDescriptor),
building: make(map[reflect.Type]bool),
}
descriptor, issue := builder.describe(root, Path{})
if issue != nil {
return nil, issue
}
if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"}
}
return &schemaDescriptor{root: descriptor, types: builder.cache}, nil
}
type schemaBuilder struct {
cache map[reflect.Type]*typeDescriptor
building map[reflect.Type]bool
}
func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) {
if descriptor, ok := b.cache[current]; ok {
return descriptor, nil
}
descriptor := &typeDescriptor{typeOf: current}
b.cache[current] = descriptor
b.building[current] = true
defer delete(b.building, current)
fail := func(code Code, message string) (*typeDescriptor, *schemaIssue) {
delete(b.cache, current)
return nil, &schemaIssue{code: code, path: path.clone(), message: message}
}
if current.Kind() == reflect.Pointer {
descriptor.kind = descriptorPointer
element, issue := b.describe(current.Elem(), path)
if issue != nil {
return nil, issue
}
descriptor.element = element
return descriptor, nil
}
if current == durationType {
descriptor.kind = descriptorDuration
return descriptor, nil
}
if current == secretType {
descriptor.kind = descriptorSecret
return descriptor, nil
}
marshals, unmarshals := textCodecSupport(current)
if marshals || unmarshals {
if !marshals || !unmarshals {
return fail(CodeSchemaTextCodec, "custom text scalar must implement both text marshal and unmarshal")
}
descriptor.kind = descriptorText
return descriptor, nil
}
switch current.Kind() {
case reflect.Struct:
descriptor.kind = descriptorStruct
descriptor.fieldByName = make(map[string]int)
if issue := b.describeFields(descriptor, current, path); issue != nil {
delete(b.cache, current)
return nil, issue
}
case reflect.Map:
if current.Key().Kind() != reflect.String {
return fail(CodeSchemaUnsupportedType, "configuration maps must have string keys")
}
descriptor.kind = descriptorMap
element, issue := b.describe(current.Elem(), path)
if issue != nil {
return nil, issue
}
descriptor.element = element
case reflect.Slice:
descriptor.kind = descriptorSlice
element, issue := b.describe(current.Elem(), path.Index(0))
if issue != nil {
return nil, issue
}
descriptor.element = element
case reflect.Array:
descriptor.kind = descriptorArray
descriptor.length = current.Len()
element, issue := b.describe(current.Elem(), path.Index(0))
if issue != nil {
return nil, issue
}
descriptor.element = element
case reflect.Interface:
if current.NumMethod() != 0 {
return fail(CodeSchemaUnsupportedType, "only the empty interface is supported for dynamic values")
}
descriptor.kind = descriptorDynamic
case reflect.String:
descriptor.kind = descriptorString
case reflect.Bool:
descriptor.kind = descriptorBoolean
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
descriptor.kind = descriptorSigned
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
descriptor.kind = descriptorUnsigned
case reflect.Float32, reflect.Float64:
descriptor.kind = descriptorFloat
default:
return fail(CodeSchemaUnsupportedType, "configuration type is not supported")
}
return descriptor, nil
}
func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue {
for i := 0; i < current.NumField(); i++ {
field := current.Field(i)
if !field.IsExported() {
continue
}
name, inline, ignored, issue := parseYAMLField(field, path)
if issue != nil {
return issue
}
if ignored {
continue
}
secret, issue := parseConfigKitField(field, path)
if issue != nil {
return issue
}
if inline {
if name != "" {
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
}
inlineType := field.Type
for inlineType.Kind() == reflect.Pointer {
inlineType = inlineType.Elem()
}
switch inlineType.Kind() {
case reflect.Struct:
if b.building[inlineType] {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "recursive inline struct is not supported"}
}
child, childIssue := b.describe(inlineType, path)
if childIssue != nil {
return childIssue
}
if child.kind != descriptorStruct {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a structural struct or string-keyed map"}
}
for _, nested := range child.fields {
nested.index = prependIndex(i, nested.index)
nested.secret = secret || nested.secret
if issue := addField(descriptor, nested, path); issue != nil {
return issue
}
}
if child.inlineMap != nil {
nested := *child.inlineMap
nested.index = prependIndex(i, nested.index)
nested.secret = secret || nested.secret
if descriptor.inlineMap != nil {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
}
descriptor.inlineMap = &nested
}
memberType, memberIssue := b.describe(field.Type, path)
if memberIssue != nil {
return memberIssue
}
descriptor.members = append(descriptor.members, structMember{index: i, descriptor: memberType, inline: true})
case reflect.Map:
if inlineType.Key().Kind() != reflect.String {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline map must have string keys"}
}
if descriptor.inlineMap != nil {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
}
fieldPath := path.Key("*")
fieldType, childIssue := b.describe(inlineType, fieldPath)
if childIssue != nil {
return childIssue
}
descriptor.inlineMap = &fieldDescriptor{index: []int{i}, descriptor: fieldType, secret: secret, inlineMap: true}
descriptor.members = append(descriptor.members, structMember{index: i, descriptor: fieldType, inline: true})
default:
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a struct, pointer to struct, or string-keyed map"}
}
continue
}
fieldPath := path.Key(name)
fieldType, childIssue := b.describe(field.Type, fieldPath)
if childIssue != nil {
return childIssue
}
if issue := addField(descriptor, fieldDescriptor{name: name, index: []int{i}, descriptor: fieldType, secret: secret}, path); issue != nil {
return issue
}
descriptor.members = append(descriptor.members, structMember{index: i, name: name, descriptor: fieldType})
}
return nil
}
func addField(owner *typeDescriptor, field fieldDescriptor, path Path) *schemaIssue {
if _, exists := owner.fieldByName[field.name]; exists {
return &schemaIssue{code: CodeSchemaAmbiguousField, path: path.Key(field.name), message: "configuration field name is ambiguous"}
}
owner.fieldByName[field.name] = len(owner.fields)
owner.fields = append(owner.fields, field)
return nil
}
func parseYAMLField(field reflect.StructField, path Path) (name string, inline, ignored bool, issue *schemaIssue) {
name = strings.ToLower(field.Name)
tag, present := field.Tag.Lookup("yaml")
if !present {
return name, false, false, nil
}
parts := strings.Split(tag, ",")
if parts[0] == "-" {
if len(parts) != 1 {
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "ignored YAML field cannot declare options"}
}
return "", false, true, nil
}
if parts[0] != "" {
name = parts[0]
}
seen := make(map[string]bool, len(parts)-1)
for _, option := range parts[1:] {
if option == "" || seen[option] {
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.Key(name), message: "YAML field tag is malformed"}
}
seen[option] = true
switch option {
case "inline":
inline = true
case "omitempty":
// ConfigKit accepts the spelling but never omits projected values.
default:
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.Key(name), message: "YAML field tag option is not supported"}
}
}
if inline && parts[0] == "" {
name = ""
}
return name, inline, false, nil
}
func parseConfigKitField(field reflect.StructField, path Path) (bool, *schemaIssue) {
tag, present := field.Tag.Lookup("configkit")
if !present || tag == "" {
return false, nil
}
if tag != "secret" {
return false, &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "ConfigKit field tag is not supported"}
}
return true, nil
}
func textCodecSupport(current reflect.Type) (marshals, unmarshals bool) {
marshals = current.Implements(textMarshalerType)
unmarshals = current.Implements(textUnmarshalerType)
if current.Kind() != reflect.Pointer {
pointer := reflect.PointerTo(current)
marshals = marshals || pointer.Implements(textMarshalerType)
unmarshals = unmarshals || pointer.Implements(textUnmarshalerType)
}
return marshals, unmarshals
}
func isStringMap(current reflect.Type) bool {
return current.Kind() == reflect.Map && current.Key().Kind() == reflect.String
}
func prependIndex(index int, nested []int) []int {
result := make([]int, 1, len(nested)+1)
result[0] = index
return append(result, nested...)
}
type schemaPath struct {
descriptor *typeDescriptor
field *fieldDescriptor
dynamic bool
}
func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
if schema == nil || schema.root == nil || !path.valid() {
return schemaPath{}, false
}
current := schema.root
var field *fieldDescriptor
for _, segment := range path.segments {
for current.kind == descriptorPointer {
current = current.element
}
switch current.kind {
case descriptorStruct:
if segment.kind == pathSegmentIndex {
return schemaPath{}, false
}
position, ok := current.fieldByName[segment.key]
if ok {
field = &current.fields[position]
current = field.descriptor
continue
}
if current.inlineMap != nil {
field = current.inlineMap
current = current.inlineMap.descriptor.element
continue
}
return schemaPath{}, false
case descriptorMap:
if segment.kind == pathSegmentIndex {
return schemaPath{}, false
}
field = nil
current = current.element
case descriptorSlice, descriptorArray:
index, ok := schemaIndex(segment)
if !ok || (current.kind == descriptorArray && index >= current.length) {
return schemaPath{}, false
}
field = nil
current = current.element
case descriptorDynamic:
return schemaPath{descriptor: current, field: field, dynamic: true}, true
default:
return schemaPath{}, false
}
}
dynamic := current
for dynamic.kind == descriptorPointer {
dynamic = dynamic.element
}
return schemaPath{descriptor: current, field: field, dynamic: dynamic.kind == descriptorDynamic}, true
}
func schemaIndex(segment pathSegment) (int, bool) {
if segment.kind == pathSegmentIndex {
return segment.index, segment.index >= 0
}
if segment.kind != pathSegmentParsed || segment.key == "" || (len(segment.key) > 1 && segment.key[0] == '0') {
return 0, false
}
index, err := strconv.Atoi(segment.key)
return index, err == nil && index >= 0
}

304
schema_test.go Normal file
View File

@@ -0,0 +1,304 @@
package configkit
import (
"reflect"
"testing"
"time"
)
type namedCount int32
type textValue struct {
value string
}
func (v textValue) MarshalText() ([]byte, error) {
return []byte(v.value), nil
}
func (v *textValue) UnmarshalText(data []byte) error {
v.value = string(data)
return nil
}
type marshalOnly string
func (v marshalOnly) MarshalText() ([]byte, error) {
return []byte(v), nil
}
type unmarshalOnly string
func (v *unmarshalOnly) UnmarshalText(data []byte) error {
*v = unmarshalOnly(data)
return nil
}
type recursiveConfig struct {
Name string `yaml:"name"`
Next *recursiveConfig `yaml:"next"`
Children map[string]recursiveConfig `yaml:"children"`
}
func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
t.Parallel()
type Embedded struct {
InlineName string `yaml:"inline_name"`
}
type nested struct {
Enabled bool `yaml:"enabled"`
}
type config struct {
HTTPServer string `yaml:"server,omitempty" configkit:"secret"`
Count namedCount `yaml:"count"`
Delay time.Duration `yaml:"delay"`
Custom textValue `yaml:"custom"`
Optional *textValue `yaml:"optional"`
Nested nested `yaml:"nested"`
Objects map[string]nested `yaml:"objects"`
Items []nested `yaml:"items"`
Fixed [2]string `yaml:"fixed"`
Dynamic any `yaml:"dynamic"`
*Embedded `yaml:",inline"`
Extras map[string]int `yaml:",inline"`
UntaggedField bool
Ignored chan int `yaml:"-"`
private chan int
}
schema, issue := buildSchema(reflect.TypeFor[config]())
if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue)
}
root := schema.root
checks := map[string]descriptorKind{
"server": descriptorString,
"count": descriptorSigned,
"delay": descriptorDuration,
"custom": descriptorText,
"optional": descriptorPointer,
"nested": descriptorStruct,
"objects": descriptorMap,
"items": descriptorSlice,
"fixed": descriptorArray,
"dynamic": descriptorDynamic,
"inline_name": descriptorString,
"untaggedfield": descriptorBoolean,
}
for name, want := range checks {
position, ok := root.fieldByName[name]
if !ok {
t.Errorf("field %q missing", name)
continue
}
got := root.fields[position].descriptor.kind
if got != want {
t.Errorf("field %q kind = %v, want %v", name, got, want)
}
}
server := root.fields[root.fieldByName["server"]]
if !server.secret {
t.Fatal("secret tag was not retained")
}
if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap {
t.Fatalf("inline map = %#v", root.inlineMap)
}
if mapped, mapIssue := buildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap {
t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue)
}
if _, exists := root.fieldByName["ignored"]; exists {
t.Fatal("ignored field was bound")
}
}
func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) {
t.Parallel()
first, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil {
t.Fatal(issue.message)
}
next := first.root.fields[first.root.fieldByName["next"]].descriptor
if next.kind != descriptorPointer || next.element != first.root {
t.Fatal("recursive pointer did not refer to the existing descriptor")
}
children := first.root.fields[first.root.fieldByName["children"]].descriptor
if children.kind != descriptorMap || children.element != first.root {
t.Fatal("recursive map did not refer to the existing descriptor")
}
second, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil {
t.Fatal(issue.message)
}
if first.root == second.root {
t.Fatal("descriptor graph was cached across loads")
}
}
func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) {
t.Parallel()
type InlineA struct {
Value string `yaml:"same"`
}
type InlineB struct {
Value string `yaml:"same"`
}
type ambiguous struct {
InlineA `yaml:",inline"`
InlineB `yaml:",inline"`
}
type directConflict struct {
Same string `yaml:"same"`
InlineA `yaml:",inline"`
}
type twoMaps struct {
First map[string]string `yaml:",inline"`
Second map[string]string `yaml:",inline"`
}
type invalidInline struct {
Value string `yaml:",inline"`
}
type invalidMap struct {
Values map[int]string `yaml:"values"`
}
type unsupported struct {
Value complex64 `yaml:"value"`
}
type badOption struct {
Value string `yaml:"value,flow"`
}
type badConfigTag struct {
Value string `configkit:"private"`
}
type missingMarshal struct {
Value unmarshalOnly `yaml:"value"`
}
type missingUnmarshal struct {
Value marshalOnly `yaml:"value"`
}
type RecursiveInline struct {
*RecursiveInline `yaml:",inline"`
}
tests := []struct {
name string
typeOf reflect.Type
code Code
}{
{name: "nil root", typeOf: nil, code: CodeSchemaRoot},
{name: "pointer root", typeOf: reflect.TypeFor[*recursiveConfig](), code: CodeSchemaRoot},
{name: "slice root", typeOf: reflect.TypeFor[[]string](), code: CodeSchemaRoot},
{name: "non-string root map", typeOf: reflect.TypeFor[map[int]string](), code: CodeSchemaRoot},
{name: "text scalar root", typeOf: reflect.TypeFor[textValue](), code: CodeSchemaRoot},
{name: "ambiguous fields", typeOf: reflect.TypeFor[ambiguous](), code: CodeSchemaAmbiguousField},
{name: "direct inline conflict", typeOf: reflect.TypeFor[directConflict](), code: CodeSchemaAmbiguousField},
{name: "multiple inline maps", typeOf: reflect.TypeFor[twoMaps](), code: CodeSchemaInline},
{name: "invalid inline", typeOf: reflect.TypeFor[invalidInline](), code: CodeSchemaInline},
{name: "invalid nested map", typeOf: reflect.TypeFor[invalidMap](), code: CodeSchemaUnsupportedType},
{name: "unsupported kind", typeOf: reflect.TypeFor[unsupported](), code: CodeSchemaUnsupportedType},
{name: "unsupported YAML option", typeOf: reflect.TypeFor[badOption](), code: CodeSchemaTag},
{name: "unsupported ConfigKit tag", typeOf: reflect.TypeFor[badConfigTag](), code: CodeSchemaTag},
{name: "missing text marshal", typeOf: reflect.TypeFor[missingMarshal](), code: CodeSchemaTextCodec},
{name: "missing text unmarshal", typeOf: reflect.TypeFor[missingUnmarshal](), code: CodeSchemaTextCodec},
{name: "recursive inline", typeOf: reflect.TypeFor[RecursiveInline](), code: CodeSchemaInline},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, issue := buildSchema(test.typeOf); issue == nil || issue.code != test.code {
t.Fatalf("issue = %#v, want %s", issue, test.code)
}
})
}
}
func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) {
t.Parallel()
type config struct {
Value panicText `yaml:"value"`
Structural yamlHookStruct `yaml:"structural"`
}
if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil {
t.Fatal(issue.message)
}
}
type panicText struct{}
type yamlHookStruct struct {
Value string `yaml:"value"`
}
func (*yamlHookStruct) UnmarshalYAML(any) error {
panic("descriptor construction called parser hook")
}
func (panicText) MarshalText() ([]byte, error) {
panic("descriptor construction called MarshalText")
}
func (*panicText) UnmarshalText([]byte) error {
panic("descriptor construction called UnmarshalText")
}
func (panicText) UnmarshalYAML(any) error {
panic("descriptor construction called parser hook")
}
func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) {
t.Parallel()
type item struct {
Name string `yaml:"name"`
}
type config struct {
Items []item `yaml:"items"`
Values map[string]item `yaml:"values"`
Fixed [2]string `yaml:"fixed"`
Dynamic any `yaml:"dynamic"`
Extras map[string]string `yaml:",inline"`
}
schema, issue := buildSchema(reflect.TypeFor[config]())
if issue != nil {
t.Fatal(issue.message)
}
valid := []Path{
Keys("items").Index(0).Key("name"),
mustPath(t, "/items/0/name"),
Keys("values", "0", "name"),
mustPath(t, "/values/0/name"),
Keys("fixed").Index(1),
Keys("dynamic", "anything").Index(9),
Keys("application_key"),
}
for _, path := range valid {
if _, ok := resolveSchemaPath(schema, path); !ok {
t.Errorf("valid path %q rejected", path.String())
}
}
invalid := []Path{
Keys("missing", "field"),
Keys("items", "0", "name"),
mustPath(t, "/items/00/name"),
Keys("fixed").Index(2),
Keys("values").Index(0),
Path{}.Index(-1),
}
for _, path := range invalid {
if _, ok := resolveSchemaPath(schema, path); ok {
t.Errorf("invalid path %q accepted", path.String())
}
}
}
func mustPath(t *testing.T, pointer string) Path {
t.Helper()
path, err := ParsePath(pointer)
if err != nil {
t.Fatal(err)
}
return path
}

75
scripts/release-check.sh Executable file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repository_dir"
baseline_toolchain=${CONFIGKIT_BASELINE_TOOLCHAIN:?set CONFIGKIT_BASELINE_TOOLCHAIN to a cached supported Go 1.25 patch release}
newer_toolchain=${CONFIGKIT_NEWER_TOOLCHAIN:?set CONFIGKIT_NEWER_TOOLCHAIN to a cached newer supported Go toolchain}
run_go() {
local toolchain=$1
shift
GOTOOLCHAIN="$toolchain" GOPROXY=off go "$@"
}
check_toolchain() {
local toolchain=$1
GOTOOLCHAIN="$toolchain" GOPROXY=off go version >/dev/null
run_go "$toolchain" mod download
}
check_toolchain "$baseline_toolchain"
check_toolchain "$newer_toolchain"
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=- |
tar --extract --file=- --directory="$temporary_dir"
(
cd "$temporary_dir"
GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off go mod tidy
)
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')
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
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
printf 'trailing whitespace found in %s\n' "$candidate_file" >&2
exit 1
fi
;;
esac
done < <(git ls-files --cached --others --exclude-standard -z)
for toolchain in "$baseline_toolchain" "$newer_toolchain"; do
run_go "$toolchain" test ./...
run_go "$toolchain" vet ./...
run_go "$toolchain" build ./...
done
run_go "$newer_toolchain" test -race ./...
run_go "$newer_toolchain" test . -run '^Example'
run_go "$newer_toolchain" test ./examples/consumers
run_go "$newer_toolchain" test . -run '^TestLocalDocumentationLinks$'
run_go "$newer_toolchain" test . -run '^TestDependencyImportBoundary$'
for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do
target_os=${target%/*}
target_arch=${target#*/}
GOTOOLCHAIN="$newer_toolchain" GOPROXY=off CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build ./...
done

131
secret.go Normal file
View File

@@ -0,0 +1,131 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
type secretDependency struct {
target Path
dependency Path
}
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication) map[string]bool {
paths := make(map[string]Path)
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
for pointer := range finalPaths {
addSecretPath(paths, pointer)
}
for _, application := range applications {
applicationPaths := make(map[string]*document.Node)
indexDocument(application.value, application.path, applicationPaths)
for pointer := range applicationPaths {
addSecretPath(paths, pointer)
}
}
var dependencies []secretDependency
for pointer, history := range metadata.history {
addSecretPath(paths, pointer)
for _, change := range history {
paths[change.Path.String()] = change.Path.clone()
for _, dependency := range change.Dependencies {
paths[dependency.String()] = dependency.clone()
dependencies = append(dependencies, secretDependency{
target: change.Path.clone(), dependency: dependency.clone(),
})
}
}
}
secret := make(map[string]bool)
for pointer, path := range paths {
if root, ok := schemaSecretRoot(schema, path); ok {
secret[root.String()] = true
}
if predicate != nil && predicate(path.clone()) {
secret[pointer] = true
}
}
for {
changed := false
for _, dependency := range dependencies {
if secretIntersectsPath(paths, secret, dependency.dependency) && !secret[dependency.target.String()] {
secret[dependency.target.String()] = true
changed = true
}
}
if !changed {
return secret
}
}
}
func addSecretPath(paths map[string]Path, pointer string) {
if path, err := ParsePath(pointer); err == nil {
paths[pointer] = path
}
}
func secretIntersectsPath(paths map[string]Path, secret map[string]bool, candidate Path) bool {
for pointer := range secret {
classified, ok := paths[pointer]
if !ok {
continue
}
if pathContains(classified, candidate) || pathContains(candidate, classified) {
return true
}
}
return false
}
func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
if schema == nil || schema.root == nil || !path.valid() {
return Path{}, false
}
current := schema.root
walked := Path{}
for _, segment := range path.segments {
walked.segments = append(walked.segments, segment)
for current.kind == descriptorPointer {
current = current.element
}
switch current.kind {
case descriptorStruct:
if segment.kind == pathSegmentIndex {
return Path{}, false
}
position, ok := current.fieldByName[segment.key]
var field *fieldDescriptor
if ok {
field = &current.fields[position]
} else {
field = current.inlineMap
}
if field == nil {
return Path{}, false
}
if field.secret {
return walked.clone(), true
}
if field.inlineMap {
current = field.descriptor.element
} else {
current = field.descriptor
}
case descriptorMap:
if segment.kind == pathSegmentIndex {
return Path{}, false
}
current = current.element
case descriptorSlice, descriptorArray:
if _, ok := schemaIndex(segment); !ok {
return Path{}, false
}
current = current.element
case descriptorDynamic:
return Path{}, false
default:
return Path{}, false
}
}
return Path{}, false
}

213
secret_reference.go Normal file
View File

@@ -0,0 +1,213 @@
package configkit
import (
"encoding/json"
"fmt"
"io"
"reflect"
"slices"
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Secret is an opaque reference to an environment variable. Its name is public
// configuration; its resolved value is available only through Value.
type Secret struct {
name string
value string
specified bool
resolved bool
}
// SecretEnv constructs an unresolved environment-backed secret reference.
// Invalid names are reported if the reference survives a configuration load.
func SecretEnv(name string) Secret {
return Secret{name: name, specified: true}
}
// EnvName returns the public environment-variable name.
func (s Secret) EnvName() string {
return s.name
}
// Value returns the resolved credential, or an empty string when unresolved.
func (s Secret) Value() string {
if !s.resolved {
return ""
}
return s.value
}
// IsResolved reports whether this reference resolved to a nonempty value during
// its configuration load.
func (s Secret) IsResolved() bool {
return s.resolved
}
// Format renders only the public reference name for every formatting verb.
func (s Secret) Format(state fmt.State, _ rune) {
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
}
// MarshalJSON emits only the public environment-variable name.
func (s Secret) MarshalJSON() ([]byte, error) {
return json.Marshal(s.name)
}
type secretLookup func(string) (string, bool)
type secretLookupResult struct {
value string
present bool
}
type secretResolutionResult struct {
lookups map[string]secretLookupResult
references map[string]string
diagnostics []Diagnostic
}
func resolveSecretReferences(root reflect.Value, schema *schemaDescriptor, input *document.Node, lookup secretLookup) secretResolutionResult {
result := secretResolutionResult{
lookups: make(map[string]secretLookupResult), references: make(map[string]string),
}
if schema == nil || schema.root == nil || !root.IsValid() || root.Type() != schema.root.typeOf {
result.diagnostics = []Diagnostic{{Code: CodeSecretRequired, Severity: SeverityError, Message: "configuration value does not match its secret-reference schema"}}
return result
}
if lookup == nil {
lookup = func(string) (string, bool) { return "", false }
}
resolveSecretValue(root, schema.root, Path{}, false, input, lookup, &result)
result.diagnostics = canonicalDiagnostics(result.diagnostics)
return result
}
func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Path, optional bool, input *document.Node, lookup secretLookup, result *secretResolutionResult) {
if descriptor.kind == descriptorPointer {
if value.IsNil() {
return
}
resolveSecretValue(value.Elem(), descriptor.element, path, descriptor.element.kind == descriptorSecret, input, lookup, result)
return
}
switch descriptor.kind {
case descriptorSecret:
reference := value.Interface().(Secret)
name := reference.EnvName()
if name == "" {
code := CodeSecretRequired
message := "required secret reference is absent"
if optional || reference.specified {
code = CodeSecretInvalidName
message = "secret environment name is empty"
}
result.diagnostics = append(result.diagnostics, secretDiagnostic(code, path, message, input))
return
}
if !validEnvironmentName(name) {
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretInvalidName, path, "secret environment name is invalid", input))
return
}
found, exists := result.lookups[name]
if !exists {
found.value, found.present = lookup(name)
result.lookups[name] = found
}
if !found.present {
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretUnset, path, "secret environment variable is not set", input))
return
}
if found.value == "" {
result.diagnostics = append(result.diagnostics, secretDiagnostic(CodeSecretEmpty, path, "secret environment variable is empty", input))
return
}
value.Set(reflect.ValueOf(Secret{name: name, value: found.value, specified: true, resolved: true}))
result.references[path.String()] = name
case descriptorStruct:
for _, member := range descriptor.members {
memberPath := path
if !member.inline {
memberPath = path.Key(member.name)
}
resolveSecretValue(value.Field(member.index), member.descriptor, memberPath, false, input, lookup, result)
}
case descriptorMap:
if value.IsNil() {
return
}
keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
for _, key := range keys {
entry := reflect.New(value.Type().Elem()).Elem()
entry.Set(value.MapIndex(key))
resolveSecretValue(entry, descriptor.element, path.Key(key.String()), false, input, lookup, result)
value.SetMapIndex(key, entry)
}
case descriptorSlice, descriptorArray:
if descriptor.kind == descriptorSlice && value.IsNil() {
return
}
for index := 0; index < value.Len(); index++ {
resolveSecretValue(value.Index(index), descriptor.element, path.Index(index), false, input, lookup, result)
}
}
}
func secretDiagnostic(code Code, path Path, message string, input *document.Node) Diagnostic {
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocation(input, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
return diagnostic
}
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()) })
return changed
}
func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor, path Path, lookups map[string]secretLookupResult, changed *[]Path) {
if descriptor.kind == descriptorPointer {
if !value.IsNil() {
collectChangedSecretStates(value.Elem(), descriptor.element, path, lookups, changed)
}
return
}
switch descriptor.kind {
case descriptorSecret:
reference := value.Interface().(Secret)
expected, ok := lookups[reference.EnvName()]
if ok && (!expected.present || expected.value == "" || !reference.resolved || reference.value != expected.value) {
*changed = append(*changed, path.clone())
}
case descriptorStruct:
for _, member := range descriptor.members {
memberPath := path
if !member.inline {
memberPath = path.Key(member.name)
}
collectChangedSecretStates(value.Field(member.index), member.descriptor, memberPath, lookups, changed)
}
case descriptorMap:
if value.IsNil() {
return
}
keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
for _, key := range keys {
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
}
case descriptorSlice, descriptorArray:
if descriptor.kind == descriptorSlice && value.IsNil() {
return
}
for index := 0; index < value.Len(); index++ {
collectChangedSecretStates(value.Index(index), descriptor.element, path.Index(index), lookups, changed)
}
}
}

323
secret_reference_test.go Normal file
View File

@@ -0,0 +1,323 @@
package configkit
import (
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"strings"
"testing"
)
type secretReferenceConfig struct {
Required Secret `yaml:"required"`
Optional *Secret `yaml:"optional"`
Map map[string]Secret `yaml:"map"`
Pointers map[string]*Secret `yaml:"pointers"`
Sequence []Secret `yaml:"sequence"`
}
func TestSecretReferencesResolveTypedShapesOncePerName(t *testing.T) {
input := `required: SHARED_SECRET
optional: OPTIONAL_SECRET
map:
first: SHARED_SECRET
pointers:
nil: null
value: POINTER_SECRET
sequence: [SHARED_SECRET, SEQUENCE_SECRET]
`
values := map[string]string{
"SHARED_SECRET": "shared-value",
"OPTIONAL_SECRET": "optional-value",
"POINTER_SECRET": "pointer-value",
"SEQUENCE_SECRET": "sequence-value",
}
counts := map[string]int{}
lookup := func(name string) (string, bool) {
counts[name]++
value, present := values[name]
return value, present
}
validationCalled := false
result, err := loadWithSecretLookup(Bytes("secrets.yml", []byte(input)), lookup, Options[secretReferenceConfig]{
Definition: Definition[secretReferenceConfig]{Validate: func(configuration *secretReferenceConfig, validation *Validation) {
validationCalled = true
if configuration.Required.Value() != "shared-value" || !configuration.Required.IsResolved() ||
configuration.Optional.Value() != "optional-value" || configuration.Pointers["value"].Value() != "pointer-value" ||
configuration.Sequence[1].Value() != "sequence-value" {
validation.Error(Keys("required"), "references were not resolved before validation")
}
}},
})
if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err)
}
if !validationCalled || result.Config.Optional == nil || result.Config.Pointers["nil"] != nil {
t.Fatalf("resolved config = %#v", result.Config)
}
if result.Config.Required.EnvName() != "SHARED_SECRET" || result.Config.Map["first"].Value() != "shared-value" {
t.Fatalf("reference names/values = %#v", result.Config)
}
if !reflect.DeepEqual(counts, map[string]int{"SHARED_SECRET": 1, "OPTIONAL_SECRET": 1, "POINTER_SECRET": 1, "SEQUENCE_SECRET": 1}) {
t.Fatalf("lookup counts = %#v", counts)
}
}
func TestSecretReferenceErrorsAreSafeAndPathSpecific(t *testing.T) {
for _, test := range []struct {
name string
input string
lookup secretLookup
code Code
}{
{name: "absent", input: "{}\n", code: CodeSecretRequired},
{name: "null", input: "required: null\n", code: CodeSecretRequired},
{name: "empty name", input: "required: ''\n", code: CodeSecretInvalidName},
{name: "invalid name", input: "required: INVALID-NAME\n", code: CodeSecretInvalidName},
{name: "unset", input: "required: MISSING_SECRET\n", code: CodeSecretUnset},
{name: "empty environment", input: "required: EMPTY_SECRET\n", lookup: func(string) (string, bool) { return "", true }, code: CodeSecretEmpty},
} {
t.Run(test.name, func(t *testing.T) {
lookup := test.lookup
if lookup == nil {
lookup = func(string) (string, bool) { return "", false }
}
_, err := loadWithSecretLookup(Bytes("secret-errors.yml", []byte(test.input)), lookup, Options[secretReferenceConfig]{})
loadError := requireSecretLoadCode(t, err, test.code)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != "/required" || strings.Contains(fmt.Sprintf("%v %#v", err, err), "CREDENTIAL-CANARY") {
t.Fatalf("diagnostic/error = %#v / %v", diagnostic, err)
}
})
}
t.Run("non-nil optional is required to resolve", func(t *testing.T) {
type config struct {
Optional *Secret `yaml:"optional"`
}
_, err := loadWithSecretLookup(Bytes("optional.yml", []byte("optional: ''\n")), func(string) (string, bool) { return "", false }, Options[config]{})
requireSecretLoadCode(t, err, CodeSecretInvalidName)
result, err := loadWithSecretLookup(Bytes("optional.yml", []byte("optional: null\n")), func(string) (string, bool) { return "", false }, Options[config]{})
if err != nil || result.Config.Optional != nil {
t.Fatalf("nil optional result = %#v, %v", result, err)
}
})
t.Run("failed lookup is cached", func(t *testing.T) {
type config struct {
Secrets []Secret `yaml:"secrets"`
}
lookups := 0
_, err := loadWithSecretLookup(Bytes("missing-twice.yml", []byte("secrets: [MISSING_SHARED, MISSING_SHARED]\n")), func(string) (string, bool) {
lookups++
return "", false
}, Options[config]{})
loadError := requireSecretLoadCode(t, err, CodeSecretUnset)
if lookups != 1 || len(loadError.Diagnostics()) != 2 {
t.Fatalf("lookups = %d, diagnostics = %#v", lookups, loadError.Diagnostics())
}
})
}
func TestResolutionSelectsEffectiveSecretNameBeforeLookup(t *testing.T) {
type config struct {
Name string `yaml:"name"`
Secret Secret `yaml:"secret"`
}
lookedUp := []string{}
validated := ""
result, err := loadWithSecretLookup(Bytes("resolved-secret.yml", []byte("name: SELECTED_SECRET\nsecret: OLD_SECRET\n")), func(name string) (string, bool) {
lookedUp = append(lookedUp, name)
return "resolved-value", true
}, Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("secret"), &configuration.Secret, SecretEnv(configuration.Name), "selected named credential", Keys("name"))
return nil
},
Validate: func(configuration *config, validation *Validation) {
validated = configuration.Secret.Value()
},
}})
if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err)
}
if !reflect.DeepEqual(lookedUp, []string{"SELECTED_SECRET"}) || validated != "resolved-value" || result.Config.Secret.EnvName() != "SELECTED_SECRET" {
t.Fatalf("lookups = %#v, validated = %q, config = %#v", lookedUp, validated, result.Config)
}
history := result.Metadata().History(Keys("secret"))
if len(history) != 3 || history[len(history)-1].Origin.Operation != ChangeNormalization {
t.Fatalf("secret history = %#v", history)
}
}
func TestValidationCannotReplaceResolvedSecretWithSameName(t *testing.T) {
type config struct {
Secret Secret `yaml:"secret"`
}
_, err := loadWithSecretLookup(Bytes("validator.yml", []byte("secret: VALIDATOR_SECRET\n")), func(string) (string, bool) {
return "CREDENTIAL-CANARY", true
}, Options[config]{Definition: Definition[config]{Validate: func(configuration *config, validation *Validation) {
configuration.Secret = SecretEnv(configuration.Secret.EnvName())
}}})
loadError := requireSecretLoadCode(t, err, CodeValidateMutation)
if loadError.Diagnostics()[0].Path.String() != "/secret" || strings.Contains(fmt.Sprintf("%v %#v", err, err), "CREDENTIAL-CANARY") {
t.Fatalf("validator mutation error = %v %#v", err, err)
}
}
func TestResolvedDefaultsAreRebuiltAndLaterLoadsRelookup(t *testing.T) {
type config struct {
Secret Secret `yaml:"secret"`
}
first, err := loadWithSecretLookup(NoSource(), func(string) (string, bool) { return "first-value", true }, Options[config]{
Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("DEFAULT_SECRET")} }},
})
if err != nil {
t.Fatal(err)
}
second, err := loadWithSecretLookup(NoSource(), func(string) (string, bool) { return "second-value", true }, Options[config]{
Definition: Definition[config]{Defaults: func() config { return first.Config }},
})
if err != nil {
t.Fatal(err)
}
if first.Config.Secret.Value() != "first-value" || second.Config.Secret.Value() != "second-value" {
t.Fatalf("resolved defaults retained stale state: %q, %q", first.Config.Secret.Value(), second.Config.Secret.Value())
}
t.Setenv("CONFIGKIT_RELOOKUP_SECRET", "one")
options := Options[config]{Definition: Definition[config]{Defaults: func() config { return config{Secret: SecretEnv("CONFIGKIT_RELOOKUP_SECRET")} }}}
realFirst, err := Load(NoSource(), options)
if err != nil {
t.Fatal(err)
}
if err := os.Setenv("CONFIGKIT_RELOOKUP_SECRET", "two"); err != nil {
t.Fatal(err)
}
realSecond, err := Load(NoSource(), options)
if err != nil {
t.Fatal(err)
}
if realFirst.Config.Secret.Value() != "one" || realSecond.Config.Secret.Value() != "two" {
t.Fatalf("later loads = %q, %q", realFirst.Config.Secret.Value(), realSecond.Config.Secret.Value())
}
}
func TestSecretSafeFormattingJSONProjectionAndHistory(t *testing.T) {
const credential = "CREDENTIAL-CANARY"
secret := Secret{name: "SAFE_SECRET_NAME", value: credential, specified: true, resolved: true}
for _, format := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%d"} {
formatted := fmt.Sprintf(format, secret)
if strings.Contains(formatted, credential) || !strings.Contains(formatted, "SAFE_SECRET_NAME") {
t.Fatalf("format %q produced %q", format, formatted)
}
}
encoded, err := json.Marshal(secret)
if err != nil || string(encoded) != `"SAFE_SECRET_NAME"` || strings.Contains(string(encoded), credential) {
t.Fatalf("MarshalJSON() = %s, %v", encoded, err)
}
type config struct {
Secret Secret `yaml:"secret"`
}
result, err := loadWithSecretLookup(Bytes("safe-secret.yml", []byte("secret: SAFE_SECRET_NAME\n")), func(string) (string, bool) {
return credential, true
}, Options[config]{})
if err != nil {
t.Fatal(err)
}
encodedConfig, err := json.Marshal(result.Config)
if err != nil || strings.Contains(string(encodedConfig), credential) || !strings.Contains(string(encodedConfig), "SAFE_SECRET_NAME") {
t.Fatalf("configuration JSON = %s, %v", encodedConfig, err)
}
var effective strings.Builder
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
outputs := []string{effective.String(), fmt.Sprintf("%#v", result.Metadata()), fmt.Sprintf("%#v", result.Metadata().History(Keys("secret"))), fmt.Sprintf("%#v", result.Config)}
for _, output := range outputs {
if strings.Contains(output, credential) {
t.Fatalf("inspection leaked credential: %q", output)
}
}
if !strings.Contains(effective.String(), "SAFE_SECRET_NAME") {
t.Fatalf("effective reference name missing: %q", effective.String())
}
}
func TestBootstrapExistingEmptyWinsAndReferenceFails(t *testing.T) {
const name = "CONFIGKIT_BLOCKED_EMPTY_SECRET"
t.Setenv(name, "")
directory := t.TempDir()
writeSecretTestFile(t, directory, name, "directory-value")
report, err := LoadSecretsDir(directory)
if err != nil {
t.Fatal(err)
}
entries := report.Entries()
if len(entries) != 1 || entries[0].Status != SecretFileSkippedExisting || !entries[0].ExistingEmpty {
t.Fatalf("bootstrap report = %#v", entries)
}
type config struct {
Secret Secret `yaml:"secret"`
}
_, err = Load[config](Bytes("blocked.yml", []byte("secret: "+name+"\n")))
requireSecretLoadCode(t, err, CodeSecretEmpty)
if value, present := os.LookupEnv(name); !present || value != "" {
t.Fatalf("environment changed to %q, %v", value, present)
}
}
func TestDynamicValuesDoNotEstablishSecretReferences(t *testing.T) {
type dynamic struct {
Secret Secret `yaml:"secret"`
}
type config struct {
Dynamic any `yaml:"dynamic"`
}
schema := mustSchemaFor[config](t)
for _, value := range []any{SecretEnv("DYNAMIC_SECRET"), dynamic{Secret: SecretEnv("DYNAMIC_SECRET")}} {
_, issue := projectConfiguration(schema, reflect.ValueOf(config{Dynamic: value}), defaultProjectionLimits())
if issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic Secret projection issue = %#v", issue)
}
}
lookups := 0
result, err := loadWithSecretLookup(Bytes("dynamic.yml", []byte("dynamic: ORDINARY_STRING\n")), func(string) (string, bool) {
lookups++
return "unexpected", true
}, Options[config]{})
if err != nil || result.Config.Dynamic != "ORDINARY_STRING" || lookups != 0 {
t.Fatalf("dynamic string result = %#v, lookups = %d, error = %v", result, lookups, err)
}
}
func TestLoadDoesNotMutateSecretEnvironment(t *testing.T) {
const name = "CONFIGKIT_UNCHANGED_SECRET"
t.Setenv(name, "original")
type config struct {
Secret Secret `yaml:"secret"`
}
result, err := Load[config](Bytes("unchanged.yml", []byte("secret: "+name+"\n")))
if err != nil || result.Config.Secret.Value() != "original" {
t.Fatalf("Load() = %#v, %v", result, err)
}
if got := os.Getenv(name); got != "original" {
t.Fatalf("environment = %q", got)
}
}
func requireSecretLoadCode(t *testing.T, err error, code Code) *LoadError {
t.Helper()
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error = %T %v, want *LoadError", err, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v, want first code %q", diagnostics, code)
}
return loadError
}

371
secrets.go Normal file
View File

@@ -0,0 +1,371 @@
package configkit
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
)
const (
secretsEntryLimit = 1024
secretFileByteLimit = int64(1 << 20)
secretsByteLimit = int64(8 << 20)
)
// SecretFileStatus describes the outcome for one file considered by
// LoadSecretsDir.
type SecretFileStatus string
const (
SecretFileLoaded SecretFileStatus = "loaded"
SecretFileSkippedExisting SecretFileStatus = "skipped_existing"
SecretFileNotApplied SecretFileStatus = "not_applied"
SecretFileFailed SecretFileStatus = "failed"
)
// SecretFileResult is the safe, value-free bootstrap result for one directory
// entry. ExistingEmpty is meaningful when Status is SecretFileSkippedExisting.
type SecretFileResult struct {
Name string
Path string
Status SecretFileStatus
ExistingEmpty bool
}
// SecretsReport is a detached snapshot of an explicit secrets-directory
// bootstrap. It does not retain file contents or environment values.
type SecretsReport struct {
entries []SecretFileResult
}
// Entries returns a copy of the report's filename-ordered entries.
func (r *SecretsReport) Entries() []SecretFileResult {
if r == nil {
return nil
}
return slices.Clone(r.entries)
}
// WriteTo renders one safe, deterministic line per entry. Names and paths are
// quoted so control characters cannot affect terminal output.
func (r *SecretsReport) WriteTo(output io.Writer) (int64, error) {
if output == nil {
return 0, errors.New("configkit: secrets report output is nil")
}
if r == nil {
return 0, nil
}
var total int64
for _, entry := range r.entries {
line := "status=" + strconv.Quote(string(entry.Status)) +
" name=" + strconv.Quote(entry.Name) +
" path=" + strconv.Quote(entry.Path) +
" existing_empty=" + strconv.FormatBool(entry.ExistingEmpty) + "\n"
written, err := io.WriteString(output, line)
total += int64(written)
if err != nil {
return total, err
}
if written != len(line) {
return total, io.ErrShortWrite
}
}
return total, nil
}
// SecretsError reports safe structured diagnostics for a failed explicit
// secrets-directory bootstrap. It intentionally does not unwrap filesystem or
// environment errors.
type SecretsError struct {
diagnostics []Diagnostic
}
// Error implements error without rendering filenames, paths, or file contents.
func (e *SecretsError) Error() string {
if e == nil {
return "configkit: secrets bootstrap failed"
}
count := 0
for _, diagnostic := range e.diagnostics {
if diagnostic.Severity == SeverityError {
count++
}
}
if count == 1 {
return "configkit: secrets bootstrap failed with 1 error"
}
return fmt.Sprintf("configkit: secrets bootstrap failed with %d errors", count)
}
// Diagnostics returns a deep copy of the bootstrap diagnostics.
func (e *SecretsError) Diagnostics() []Diagnostic {
if e == nil {
return nil
}
return cloneDiagnostics(e.diagnostics)
}
type secretsEnvironment struct {
lookup func(string) (string, bool)
set func(string, string) error
}
type secretsAccess struct {
openDirectory func(string) (*os.File, error)
lstat func(string) (fs.FileInfo, error)
openFile func(string) (*os.File, error)
}
type stagedSecret struct {
entry int
value string
}
// LoadSecretsDir explicitly loads immediate regular files from directory into
// previously unset process environment variables. Applications should call it
// once during startup, before concurrent work; Load never calls it.
func LoadSecretsDir(directory string) (*SecretsReport, error) {
return loadSecretsDir(directory, secretsEnvironment{lookup: os.LookupEnv, set: os.Setenv}, secretsAccess{
openDirectory: os.Open,
lstat: os.Lstat,
openFile: os.Open,
})
}
func loadSecretsDir(directory string, environment secretsEnvironment, access secretsAccess) (*SecretsReport, error) {
report := &SecretsReport{}
if strings.TrimSpace(directory) == "" {
return report, secretsFailure(CodeSecretsInvalidPath, "secrets directory path is required")
}
canonical, err := canonicalExistingPath(directory)
if err != nil {
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be canonicalized: "+strconv.Quote(cleanAbsolutePath(directory)))
}
info, err := os.Stat(canonical)
if err != nil {
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be inspected: "+strconv.Quote(canonical))
}
if !info.IsDir() {
return report, secretsFailure(CodeSecretsInvalidPath, "secrets directory is not a directory: "+strconv.Quote(canonical))
}
directoryFile, err := access.openDirectory(canonical)
if err != nil {
return report, secretsFailure(classifySecretsFilesystemError(err), "secrets directory could not be opened: "+strconv.Quote(canonical))
}
entries, readErr := readSecretsEntries(directoryFile)
closeErr := directoryFile.Close()
if readErr != nil {
return report, secretsFailure(classifySecretsFilesystemError(readErr), "secrets directory could not be enumerated: "+strconv.Quote(canonical))
}
if closeErr != nil {
return report, secretsFailure(CodeSecretsRead, "secrets directory could not be closed: "+strconv.Quote(canonical))
}
slices.SortFunc(entries, func(left, right fs.DirEntry) int { return strings.Compare(left.Name(), right.Name()) })
report.entries = make([]SecretFileResult, len(entries))
for index, entry := range entries {
name := entry.Name()
report.entries[index] = SecretFileResult{Name: name, Path: filepath.Join(canonical, name), Status: SecretFileNotApplied}
}
if len(entries) > secretsEntryLimit {
return report, secretsFailure(CodeSecretsLimitExceeded, "secrets directory exceeds the 1024-entry limit: "+strconv.Quote(canonical))
}
for index := range report.entries {
entry := &report.entries[index]
if !validEnvironmentName(entry.Name) {
entry.Status = SecretFileFailed
return report, secretsFailure(CodeSecretsInvalidName, "secret filename is not a valid environment name: "+strconv.Quote(entry.Path))
}
info, err := access.lstat(entry.Path)
if err != nil {
entry.Status = SecretFileFailed
return report, secretsFailure(classifySecretsFilesystemError(err), "secret file could not be inspected: "+strconv.Quote(entry.Path))
}
if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() {
entry.Status = SecretFileFailed
return report, secretsFailure(CodeSecretsNotRegular, "secret entry is not a regular file: "+strconv.Quote(entry.Path))
}
}
staged := make([]stagedSecret, 0, len(report.entries))
var consumed int64
for index := range report.entries {
entry := &report.entries[index]
if existing, present := environment.lookup(entry.Name); present {
entry.Status = SecretFileSkippedExisting
entry.ExistingEmpty = existing == ""
continue
}
remaining := secretsByteLimit - consumed
value, used, err := readSecretValue(entry.Path, remaining, access)
if err != nil {
entry.Status = SecretFileFailed
return report, secretsFailure(secretReadCode(err), secretReadMessage(err)+": "+strconv.Quote(entry.Path))
}
consumed += used
staged = append(staged, stagedSecret{entry: index, value: value})
}
for _, secret := range staged {
entry := &report.entries[secret.entry]
if existing, present := environment.lookup(entry.Name); present {
entry.Status = SecretFileSkippedExisting
entry.ExistingEmpty = existing == ""
continue
}
if err := environment.set(entry.Name, secret.value); err != nil {
entry.Status = SecretFileFailed
return report, secretsFailure(CodeSecretsApplyFailed, "secret environment variable could not be set for file: "+strconv.Quote(entry.Path))
}
entry.Status = SecretFileLoaded
}
return report, nil
}
func readSecretsEntries(directory *os.File) ([]fs.DirEntry, error) {
entries := make([]fs.DirEntry, 0)
for len(entries) <= secretsEntryLimit {
batch, err := directory.ReadDir(secretsEntryLimit + 1 - len(entries))
entries = append(entries, batch...)
if err == io.EOF {
return entries, nil
}
if err != nil {
return entries, err
}
if len(entries) > secretsEntryLimit {
return entries, nil
}
}
return entries, nil
}
type secretReadFailure int
const (
secretReadFilesystem secretReadFailure = iota
secretReadFileLimit
secretReadTotalLimit
secretReadNUL
)
type secretReadError struct {
kind secretReadFailure
err error
}
func (e *secretReadError) Error() string { return "secret read failed" }
func readSecretValue(path string, remaining int64, access secretsAccess) (string, int64, error) {
if remaining < 0 {
return "", 0, &secretReadError{kind: secretReadTotalLimit}
}
limit := min(secretFileByteLimit, remaining)
file, err := access.openFile(path)
if err != nil {
return "", 0, &secretReadError{kind: secretReadFilesystem, err: err}
}
data, readErr := io.ReadAll(io.LimitReader(file, limit+1))
closeErr := file.Close()
if readErr != nil {
return "", 0, &secretReadError{kind: secretReadFilesystem, err: readErr}
}
if closeErr != nil {
return "", 0, &secretReadError{kind: secretReadFilesystem, err: closeErr}
}
if int64(len(data)) > limit {
kind := secretReadFileLimit
if remaining < secretFileByteLimit {
kind = secretReadTotalLimit
}
return "", 0, &secretReadError{kind: kind}
}
if slices.Contains(data, byte(0)) {
return "", 0, &secretReadError{kind: secretReadNUL}
}
value := string(data)
if strings.HasSuffix(value, "\r\n") {
value = strings.TrimSuffix(value, "\r\n")
} else {
value = strings.TrimSuffix(value, "\n")
}
return value, int64(len(data)), nil
}
func validEnvironmentName(name string) bool {
if name == "" || !asciiEnvironmentFirst(name[0]) {
return false
}
for index := 1; index < len(name); index++ {
character := name[index]
if !asciiEnvironmentFirst(character) && (character < '0' || character > '9') {
return false
}
}
return true
}
func asciiEnvironmentFirst(character byte) bool {
return character == '_' || character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z'
}
func cleanAbsolutePath(path string) string {
absolute, err := filepath.Abs(path)
if err != nil {
return filepath.Clean(path)
}
return filepath.Clean(absolute)
}
func classifySecretsFilesystemError(err error) Code {
switch {
case errors.Is(err, fs.ErrNotExist):
return CodeSecretsMissing
case errors.Is(err, fs.ErrPermission):
return CodeSecretsPermission
default:
return CodeSecretsRead
}
}
func secretReadCode(err error) Code {
var failure *secretReadError
if errors.As(err, &failure) {
switch failure.kind {
case secretReadFileLimit, secretReadTotalLimit:
return CodeSecretsLimitExceeded
case secretReadNUL:
return CodeSecretsInvalidValue
case secretReadFilesystem:
return classifySecretsFilesystemError(failure.err)
}
}
return CodeSecretsRead
}
func secretReadMessage(err error) string {
var failure *secretReadError
if errors.As(err, &failure) {
switch failure.kind {
case secretReadFileLimit:
return "secret file exceeds the 1 MiB limit"
case secretReadTotalLimit:
return "eligible secret files exceed the 8 MiB total limit"
case secretReadNUL:
return "secret file contains a NUL byte"
}
}
return "secret file could not be read"
}
func secretsFailure(code Code, message string) *SecretsError {
return &SecretsError{diagnostics: []Diagnostic{{Code: code, Severity: SeverityError, Message: message}}}
}

377
secrets_test.go Normal file
View File

@@ -0,0 +1,377 @@
package configkit
import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
)
func TestLoadSecretsDirLoadsCanonicalSortedValues(t *testing.T) {
directory := t.TempDir()
writeSecretTestFile(t, directory, "CONFIGKIT_SECRET_Z", "last\n\n")
writeSecretTestFile(t, directory, "CONFIGKIT_SECRET_A", "first\r\n")
writeSecretTestFile(t, directory, "CONFIGKIT_SECRET_EMPTY", "")
selected := directory
if runtime.GOOS != "windows" {
selected = filepath.Join(t.TempDir(), "selected")
if err := os.Symlink(directory, selected); err != nil {
t.Fatalf("Symlink() error = %v", err)
}
}
for _, name := range []string{"CONFIGKIT_SECRET_A", "CONFIGKIT_SECRET_EMPTY", "CONFIGKIT_SECRET_Z"} {
t.Setenv(name, "temporary")
if err := os.Unsetenv(name); err != nil {
t.Fatalf("Unsetenv(%q) error = %v", name, err)
}
}
report, err := LoadSecretsDir(selected)
if err != nil {
t.Fatalf("LoadSecretsDir() error = %v", err)
}
if got := os.Getenv("CONFIGKIT_SECRET_A"); got != "first" {
t.Fatalf("LF value = %q, want first", got)
}
if got := os.Getenv("CONFIGKIT_SECRET_Z"); got != "last\n" {
t.Fatalf("two-LF value = %q, want one retained LF", got)
}
if value, present := os.LookupEnv("CONFIGKIT_SECRET_EMPTY"); !present || value != "" {
t.Fatalf("empty value = %q, %v; want present empty", value, present)
}
canonical := canonicalTestPath(t, directory)
want := []SecretFileResult{
{Name: "CONFIGKIT_SECRET_A", Path: filepath.Join(canonical, "CONFIGKIT_SECRET_A"), Status: SecretFileLoaded},
{Name: "CONFIGKIT_SECRET_EMPTY", Path: filepath.Join(canonical, "CONFIGKIT_SECRET_EMPTY"), Status: SecretFileLoaded},
{Name: "CONFIGKIT_SECRET_Z", Path: filepath.Join(canonical, "CONFIGKIT_SECRET_Z"), Status: SecretFileLoaded},
}
if got := report.Entries(); !reflect.DeepEqual(got, want) {
t.Fatalf("Entries() = %#v, want %#v", got, want)
}
entries := report.Entries()
entries[0].Name = "mutated"
if report.Entries()[0].Name != want[0].Name {
t.Fatal("Entries() did not return a detached slice")
}
}
func TestLoadSecretsDirPreservesExistingWithoutReading(t *testing.T) {
directory := t.TempDir()
writeSecretTestFile(t, directory, "EMPTY_WINNER", strings.Repeat("CANARY", int(secretFileByteLimit)))
writeSecretTestFile(t, directory, "VALUE_WINNER", "CANARY-CONTENT")
environment := map[string]string{"EMPTY_WINNER": "", "VALUE_WINNER": "existing"}
opened := 0
access := testSecretsAccess()
access.openFile = func(string) (*os.File, error) {
opened++
return nil, errors.New("must not open")
}
report, err := loadSecretsDir(directory, mapSecretsEnvironment(environment), access)
if err != nil {
t.Fatalf("loadSecretsDir() error = %v", err)
}
if opened != 0 {
t.Fatalf("opened skipped files %d times", opened)
}
want := []SecretFileResult{
{Name: "EMPTY_WINNER", Path: filepath.Join(canonicalTestPath(t, directory), "EMPTY_WINNER"), Status: SecretFileSkippedExisting, ExistingEmpty: true},
{Name: "VALUE_WINNER", Path: filepath.Join(canonicalTestPath(t, directory), "VALUE_WINNER"), Status: SecretFileSkippedExisting},
}
if got := report.Entries(); !reflect.DeepEqual(got, want) {
t.Fatalf("Entries() = %#v, want %#v", got, want)
}
}
func TestLoadSecretsDirValidatesAllMetadataBeforeReadingOrWriting(t *testing.T) {
for _, test := range []struct {
name string
make func(*testing.T, string)
code Code
}{
{name: "invalid name", make: func(t *testing.T, directory string) { writeSecretTestFile(t, directory, "1INVALID", "CANARY") }, code: CodeSecretsInvalidName},
{name: "directory", make: func(t *testing.T, directory string) {
if err := os.Mkdir(filepath.Join(directory, "BROKEN"), 0o700); err != nil {
t.Fatal(err)
}
}, code: CodeSecretsNotRegular},
{name: "symlink", make: func(t *testing.T, directory string) {
if runtime.GOOS == "windows" {
t.Skip("symlink setup differs on Windows")
}
writeSecretTestFile(t, directory, "TARGET", "CANARY")
if err := os.Symlink(filepath.Join(directory, "TARGET"), filepath.Join(directory, "BROKEN")); err != nil {
t.Fatal(err)
}
}, code: CodeSecretsNotRegular},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
writeSecretTestFile(t, directory, "A_VALID", "first")
test.make(t, directory)
writes := 0
access := testSecretsAccess()
opened := 0
access.openFile = func(path string) (*os.File, error) { opened++; return os.Open(path) }
report, err := loadSecretsDir(directory, secretsEnvironment{
lookup: func(string) (string, bool) { return "", false },
set: func(string, string) error { writes++; return nil },
}, access)
requireSecretsCode(t, err, test.code)
if writes != 0 || opened != 0 {
t.Fatalf("writes = %d, opens = %d; want no preflight activity", writes, opened)
}
if report == nil || len(report.Entries()) < 2 {
t.Fatalf("report = %#v, want enumerated entries", report)
}
})
}
}
func TestLoadSecretsDirPreflightFailuresDoNotWrite(t *testing.T) {
for _, test := range []struct {
name string
body string
code Code
}{
{name: "file limit", body: strings.Repeat("x", int(secretFileByteLimit)+1), code: CodeSecretsLimitExceeded},
{name: "NUL", body: "before\x00CANARY", code: CodeSecretsInvalidValue},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
writeSecretTestFile(t, directory, "A_FIRST", "first")
writeSecretTestFile(t, directory, "Z_FAILED", test.body)
writes := 0
report, err := loadSecretsDir(directory, secretsEnvironment{
lookup: func(string) (string, bool) { return "", false },
set: func(string, string) error { writes++; return nil },
}, testSecretsAccess())
requireSecretsCode(t, err, test.code)
if writes != 0 {
t.Fatalf("writes = %d, want zero", writes)
}
entries := report.Entries()
if entries[0].Status != SecretFileNotApplied || entries[1].Status != SecretFileFailed {
t.Fatalf("statuses = %#v", entries)
}
})
}
}
func TestLoadSecretsDirClassifiesReadFailuresWithoutLeakingThem(t *testing.T) {
for _, test := range []struct {
name string
err error
code Code
}{
{name: "permission", err: fs.ErrPermission, code: CodeSecretsPermission},
{name: "missing", err: fs.ErrNotExist, code: CodeSecretsMissing},
{name: "other", err: errors.New("READ-ERROR-CANARY"), code: CodeSecretsRead},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
writeSecretTestFile(t, directory, "SECRET", "CREDENTIAL-CANARY")
access := testSecretsAccess()
access.openFile = func(string) (*os.File, error) { return nil, test.err }
writes := 0
report, err := loadSecretsDir(directory, secretsEnvironment{
lookup: func(string) (string, bool) { return "", false },
set: func(string, string) error { writes++; return nil },
}, access)
requireSecretsCode(t, err, test.code)
if writes != 0 || report.Entries()[0].Status != SecretFileFailed {
t.Fatalf("writes = %d, entries = %#v", writes, report.Entries())
}
if strings.Contains(fmt.Sprintf("%v %#v", err, err), "CANARY") {
t.Fatalf("error leaked underlying failure: %v %#v", err, err)
}
})
}
}
func TestLoadSecretsDirEnforcesEnumerationAndTotalBounds(t *testing.T) {
t.Run("entries", func(t *testing.T) {
directory := t.TempDir()
for index := 0; index <= secretsEntryLimit; index++ {
writeSecretTestFile(t, directory, fmt.Sprintf("SECRET_%04d", index), "")
}
report, err := loadSecretsDir(directory, mapSecretsEnvironment(map[string]string{}), testSecretsAccess())
requireSecretsCode(t, err, CodeSecretsLimitExceeded)
if len(report.Entries()) != secretsEntryLimit+1 {
t.Fatalf("entries = %d, want %d bounded enumerated entries", len(report.Entries()), secretsEntryLimit+1)
}
})
t.Run("total bytes", func(t *testing.T) {
directory := t.TempDir()
for index := 0; index < 9; index++ {
writeSecretTestFile(t, directory, fmt.Sprintf("SECRET_%02d", index), strings.Repeat("x", int(secretFileByteLimit)))
}
writes := 0
report, err := loadSecretsDir(directory, secretsEnvironment{
lookup: func(string) (string, bool) { return "", false },
set: func(string, string) error { writes++; return nil },
}, testSecretsAccess())
requireSecretsCode(t, err, CodeSecretsLimitExceeded)
if writes != 0 || report.Entries()[8].Status != SecretFileFailed {
t.Fatalf("writes = %d, final status = %q", writes, report.Entries()[8].Status)
}
})
}
func TestLoadSecretsDirApplyReportsPartialFailureAndPresenceRace(t *testing.T) {
directory := t.TempDir()
for _, name := range []string{"A_LOADED", "B_RACED", "C_FAILED", "D_WAITING"} {
writeSecretTestFile(t, directory, name, "CANARY-"+name)
}
values := map[string]string{}
lookups := map[string]int{}
environment := secretsEnvironment{
lookup: func(name string) (string, bool) {
lookups[name]++
if name == "B_RACED" && lookups[name] == 2 {
values[name] = ""
}
value, present := values[name]
return value, present
},
set: func(name, value string) error {
if name == "C_FAILED" {
return errors.New("CANARY set error")
}
values[name] = value
return nil
},
}
report, err := loadSecretsDir(directory, environment, testSecretsAccess())
requireSecretsCode(t, err, CodeSecretsApplyFailed)
entries := report.Entries()
wantStatuses := []SecretFileStatus{SecretFileLoaded, SecretFileSkippedExisting, SecretFileFailed, SecretFileNotApplied}
for index, want := range wantStatuses {
if entries[index].Status != want {
t.Fatalf("entry %d status = %q, want %q", index, entries[index].Status, want)
}
}
if !entries[1].ExistingEmpty || values["A_LOADED"] != "CANARY-A_LOADED" {
t.Fatalf("partial report/state = %#v, %#v", entries, values)
}
if _, present := values["D_WAITING"]; present {
t.Fatal("entry after apply failure was written")
}
}
func TestLoadSecretsDirFailuresAreSafeAndDetached(t *testing.T) {
directory := t.TempDir()
writeSecretTestFile(t, directory, "SECRET", "CREDENTIAL-CANARY\x00")
report, err := loadSecretsDir(directory, mapSecretsEnvironment(map[string]string{}), testSecretsAccess())
secretsErr := requireSecretsCode(t, err, CodeSecretsInvalidValue)
diagnostics := secretsErr.Diagnostics()
diagnostics[0].Message = "mutated"
if secretsErr.Diagnostics()[0].Message == "mutated" {
t.Fatal("Diagnostics() did not return a detached slice")
}
var rendered strings.Builder
if _, writeErr := report.WriteTo(&rendered); writeErr != nil {
t.Fatal(writeErr)
}
jsonReport, marshalErr := json.Marshal(report)
if marshalErr != nil {
t.Fatal(marshalErr)
}
outputs := []string{err.Error(), fmt.Sprintf("%v %#v", err, err), fmt.Sprintf("%v %#v", report, report), rendered.String(), string(jsonReport)}
for _, output := range outputs {
if strings.Contains(output, "CREDENTIAL-CANARY") {
t.Fatalf("safe output leaked content: %q", output)
}
}
if secretsErr.Diagnostics()[0].Primary != nil || secretsErr.Diagnostics()[0].Path.String() != "" {
t.Fatalf("bootstrap diagnostic fabricated source location/path: %#v", secretsErr.Diagnostics()[0])
}
}
func TestSecretsReportWriteToPropagatesWriterFailures(t *testing.T) {
report := &SecretsReport{entries: []SecretFileResult{{Name: "A\nB", Path: "/tmp/A\nB", Status: SecretFileLoaded}}}
var output strings.Builder
written, err := report.WriteTo(&output)
if err != nil || written != int64(output.Len()) || strings.Contains(output.String(), "A\nB") || !strings.Contains(output.String(), `A\nB`) {
t.Fatalf("WriteTo() = (%d, %v), output %q", written, err, output.String())
}
failure := errors.New("writer CANARY")
written, err = report.WriteTo(errorAfterWriter{limit: 5, err: failure})
if written != 5 || !errors.Is(err, failure) {
t.Fatalf("failing WriteTo() = (%d, %v)", written, err)
}
written, err = report.WriteTo(shortWriter{})
if written == 0 || !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short WriteTo() = (%d, %v)", written, err)
}
}
func TestLoadSecretsDirRejectsMissingAndInvalidDirectory(t *testing.T) {
report, err := LoadSecretsDir("")
requireSecretsCode(t, err, CodeSecretsInvalidPath)
if report == nil || len(report.Entries()) != 0 {
t.Fatalf("empty path report = %#v", report)
}
report, err = LoadSecretsDir(filepath.Join(t.TempDir(), "missing"))
requireSecretsCode(t, err, CodeSecretsMissing)
if report == nil || len(report.Entries()) != 0 {
t.Fatalf("missing path report = %#v", report)
}
file := filepath.Join(t.TempDir(), "FILE")
if err := os.WriteFile(file, nil, 0o600); err != nil {
t.Fatal(err)
}
_, err = LoadSecretsDir(file)
requireSecretsCode(t, err, CodeSecretsInvalidPath)
}
func testSecretsAccess() secretsAccess {
return secretsAccess{openDirectory: os.Open, lstat: os.Lstat, openFile: os.Open}
}
func mapSecretsEnvironment(values map[string]string) secretsEnvironment {
return secretsEnvironment{
lookup: func(name string) (string, bool) { value, present := values[name]; return value, present },
set: func(name, value string) error { values[name] = value; return nil },
}
}
func writeSecretTestFile(t *testing.T, directory, name, value string) {
t.Helper()
if err := os.WriteFile(filepath.Join(directory, name), []byte(value), 0o600); err != nil {
t.Fatalf("WriteFile(%q) error = %v", name, err)
}
}
func requireSecretsCode(t *testing.T, err error, code Code) *SecretsError {
t.Helper()
var secretsErr *SecretsError
if !errors.As(err, &secretsErr) {
t.Fatalf("error = %T %v, want *SecretsError", err, err)
}
diagnostics := secretsErr.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v, want code %q", diagnostics, code)
}
return secretsErr
}
type errorAfterWriter struct {
limit int
err error
}
func (w errorAfterWriter) Write(data []byte) (int, error) {
return min(w.limit, len(data)), w.err
}

241
source.go Normal file
View File

@@ -0,0 +1,241 @@
package configkit
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"reflect"
"sync/atomic"
)
const (
maxSourceBytes = 8 << 20
maxTotalBytes = 64 << 20
)
// Source is an opaque configuration input. Construct sources with File, Bytes,
// Reader, or NoSource; the zero value is invalid.
type Source struct {
kind sourceKind
path string
label string
data []byte
reader *readerSource
createErr sourceCreationError
}
type sourceKind uint8
const (
sourceInvalid sourceKind = iota
sourceFile
sourceBytes
sourceReader
sourceNone
)
type sourceCreationError uint8
const (
creationValid sourceCreationError = iota
creationInvalidPath
creationInvalidLabel
creationInvalidReader
creationTooLarge
)
type readerSource struct {
input io.Reader
used atomic.Bool
}
// File returns a source that reopens path for every load attempt.
func File(path string) Source {
if path == "" {
return Source{kind: sourceFile, createErr: creationInvalidPath}
}
return Source{kind: sourceFile, path: path, label: path}
}
// Bytes snapshots data immediately. Label must be nonempty and safe to display;
// constructor problems are reported when the source is consumed.
func Bytes(label string, data []byte) Source {
source := Source{kind: sourceBytes, label: label}
if label == "" {
source.createErr = creationInvalidLabel
return source
}
if len(data) > maxSourceBytes {
source.createErr = creationTooLarge
return source
}
source.data = bytes.Clone(data)
return source
}
// Reader returns a single-use source. ConfigKit consumes input but never closes it.
// Copies of the returned Source share the same single-use claim.
func Reader(label string, input io.Reader) Source {
source := Source{kind: sourceReader, label: label}
if label == "" {
source.createErr = creationInvalidLabel
return source
}
if nilReader(input) {
source.createErr = creationInvalidReader
return source
}
source.reader = &readerSource{input: input}
return source
}
// NoSource explicitly selects a defaults-only input. It creates no source record.
func NoSource() Source {
return Source{kind: sourceNone}
}
type sourceLimits struct {
perSource int64
remaining int64
}
func defaultSourceLimits() sourceLimits {
return sourceLimits{perSource: maxSourceBytes, remaining: maxTotalBytes}
}
type sourceRead struct {
data []byte
info *SourceInfo
}
func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead, []Diagnostic) {
if diagnostic := sourceConstructorDiagnostic(source); diagnostic != nil {
return sourceRead{}, []Diagnostic{*diagnostic}
}
if source.kind == sourceNone {
return sourceRead{}, nil
}
limit := min(limits.perSource, limits.remaining)
if limit < 0 {
limit = 0
}
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
var (
input io.Reader
close func() error
)
switch source.kind {
case sourceFile:
absolute, err := filepath.Abs(source.path)
if err != nil {
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")}
}
input = file
case sourceBytes:
input = bytes.NewReader(source.data)
case sourceReader:
if !source.reader.used.CompareAndSwap(false, true) {
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceReaderUsed, sourceID, "reader source has already been consumed")}
}
input = source.reader.input
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")}
}
return sourceRead{data: bytes.Clone(data), info: info}, nil
}
func sourceConstructorDiagnostic(source Source) *Diagnostic {
var message string
code := CodeSourceInvalid
switch source.createErr {
case creationValid:
if source.kind != sourceInvalid {
return nil
}
message = "source is invalid"
case creationInvalidPath:
message = "source path must not be empty"
case creationInvalidLabel:
message = "source label must not be empty"
case creationInvalidReader:
message = "source reader must not be nil"
case creationTooLarge:
code = CodeSourceLimitExceeded
message = "source exceeds the configured byte limit"
}
diagnostic := sourceDiagnostic(code, "", message)
return &diagnostic
}
func classifySourceError(err error, sourceID string) Diagnostic {
switch {
case errors.Is(err, fs.ErrNotExist):
return sourceDiagnostic(CodeSourceMissing, sourceID, "source does not exist")
case errors.Is(err, fs.ErrPermission):
return sourceDiagnostic(CodeSourcePermission, sourceID, "source cannot be accessed")
default:
return sourceDiagnostic(CodeSourceRead, sourceID, "source could not be read")
}
}
func sourceDiagnostic(code Code, sourceID, message string) Diagnostic {
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Message: message}
if sourceID != "" {
diagnostic.Primary = &Location{SourceID: sourceID}
}
return diagnostic
}
func nilReader(input io.Reader) bool {
if input == nil {
return true
}
value := reflect.ValueOf(input)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}

202
source_test.go Normal file
View File

@@ -0,0 +1,202 @@
package configkit
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func TestSourceConstructorsAndOwnership(t *testing.T) {
t.Parallel()
data := []byte("value: before\n")
source := Bytes("memory", data)
copy(data, "value: after!\n")
read, diagnostics := readSource(source, "source-1", defaultSourceLimits())
if len(diagnostics) != 0 || string(read.data) != "value: before\n" {
t.Fatalf("byte snapshot = %q, diagnostics = %#v", read.data, diagnostics)
}
read.data[0] = 'X'
again, diagnostics := readSource(source, "source-1", defaultSourceLimits())
if len(diagnostics) != 0 || string(again.data) != "value: before\n" {
t.Fatal("returned bytes alias source snapshot")
}
closer := &observedReadCloser{Reader: strings.NewReader("value: reader\n")}
readerSource := Reader("stream", closer)
read, diagnostics = readSource(readerSource, "source-1", defaultSourceLimits())
if len(diagnostics) != 0 || string(read.data) != "value: reader\n" || closer.closed {
t.Fatalf("reader result = %q, closed=%v diagnostics=%#v", read.data, closer.closed, diagnostics)
}
_, diagnostics = readSource(readerSource, "source-1", defaultSourceLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceReaderUsed {
t.Fatalf("second reader diagnostics = %#v", diagnostics)
}
if read, diagnostics := readSource(NoSource(), "source-1", defaultSourceLimits()); len(diagnostics) != 0 || read.info != nil || read.data != nil {
t.Fatalf("NoSource read = %#v, %#v", read, diagnostics)
}
}
func TestFileSourcesReopenAndClassifyFailures(t *testing.T) {
t.Parallel()
directory := t.TempDir()
path := filepath.Join(directory, "config.yml")
if err := os.WriteFile(path, []byte("value: first\n"), 0o600); err != nil {
t.Fatal(err)
}
source := File(path)
first, diagnostics := readSource(source, "source-1", defaultSourceLimits())
if len(diagnostics) != 0 || string(first.data) != "value: first\n" || first.info.Path != path {
t.Fatalf("first read = %#v, %#v", first, diagnostics)
}
if err := os.WriteFile(path, []byte("value: second\n"), 0o600); err != nil {
t.Fatal(err)
}
second, diagnostics := readSource(source, "source-1", defaultSourceLimits())
if len(diagnostics) != 0 || string(second.data) != "value: second\n" {
t.Fatalf("second read = %#v, %#v", second, diagnostics)
}
missing, diagnostics := readSource(File(filepath.Join(directory, "missing.yml")), "source-2", defaultSourceLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceMissing || missing.info == nil {
t.Fatalf("missing diagnostics = %#v, info=%#v", diagnostics, missing.info)
}
_, diagnostics = readSource(File(directory), "source-3", defaultSourceLimits())
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceNotRegular {
t.Fatalf("directory diagnostics = %#v", diagnostics)
}
permissionPath := filepath.Join(directory, "private.yml")
if err := os.WriteFile(permissionPath, []byte("value: private\n"), 0); err != nil {
t.Fatal(err)
}
_, diagnostics = readSource(File(permissionPath), "source-4", defaultSourceLimits())
if os.Geteuid() != 0 {
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourcePermission {
t.Fatalf("permission diagnostics = %#v", diagnostics)
}
}
}
func TestSourceReadLimitsAndErrors(t *testing.T) {
t.Parallel()
limits := sourceLimits{perSource: 5, remaining: 20}
read, diagnostics := readSource(Reader("exact", &fragmentReader{data: []byte("12345"), width: 1}), "source-1", limits)
if len(diagnostics) != 0 || string(read.data) != "12345" {
t.Fatalf("exact limit read = %q, %#v", read.data, diagnostics)
}
_, diagnostics = readSource(Reader("over", strings.NewReader("123456")), "source-2", limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("over-limit diagnostics = %#v", diagnostics)
}
_, diagnostics = readSource(Reader("total", strings.NewReader("1234")), "source-3", sourceLimits{perSource: 10, remaining: 3})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("total-limit diagnostics = %#v", diagnostics)
}
want := errors.New("read canary: must not escape")
_, diagnostics = readSource(Reader("failing", &dataErrorReader{data: []byte("partial"), err: want}), "source-4", limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceRead || strings.Contains(diagnostics[0].Message, "canary") {
t.Fatalf("read-error diagnostics = %#v", diagnostics)
}
}
func TestReaderSourceCopiesCompeteForOneClaim(t *testing.T) {
t.Parallel()
source := Reader("shared", strings.NewReader("value: one\n"))
copyOfSource := source
start := make(chan struct{})
results := make(chan []Diagnostic, 2)
var group sync.WaitGroup
for _, candidate := range []Source{source, copyOfSource} {
group.Add(1)
go func(candidate Source) {
defer group.Done()
<-start
_, diagnostics := readSource(candidate, "source-1", defaultSourceLimits())
results <- diagnostics
}(candidate)
}
close(start)
group.Wait()
close(results)
successes, used := 0, 0
for diagnostics := range results {
if len(diagnostics) == 0 {
successes++
} else if len(diagnostics) == 1 && diagnostics[0].Code == CodeSourceReaderUsed {
used++
}
}
if successes != 1 || used != 1 {
t.Fatalf("successes=%d reader-used=%d", successes, used)
}
}
func TestInvalidSourcesFailWithoutConsumption(t *testing.T) {
t.Parallel()
var typedNil *strings.Reader
for name, source := range map[string]Source{
"zero": {},
"empty file": File(""),
"empty bytes": Bytes("", nil),
"nil reader": Reader("reader", nil),
"typed nil": Reader("reader", typedNil),
"large bytes": Bytes("large", make([]byte, maxSourceBytes+1)),
} {
t.Run(name, func(t *testing.T) {
_, diagnostics := readSource(source, "source-1", defaultSourceLimits())
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
})
}
}
type observedReadCloser struct {
io.Reader
closed bool
}
func (r *observedReadCloser) Close() error {
r.closed = true
return nil
}
type fragmentReader struct {
data []byte
width int
}
func (r *fragmentReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := min(len(p), r.width, len(r.data))
copy(p, r.data[:n])
r.data = r.data[n:]
return n, nil
}
type dataErrorReader struct {
data []byte
err error
}
func (r *dataErrorReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, r.err
}
n := copy(p, r.data)
r.data = r.data[n:]
return n, r.err
}

168
validation.go Normal file
View File

@@ -0,0 +1,168 @@
package configkit
import (
"reflect"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Validation collects safe application diagnostics during a supported callback.
// A callback must not retain the context or use it concurrently.
type Validation struct {
state *validationState
}
type validationState struct {
active bool
errorCode Code
warningCode Code
invalidPath Code
schema *schemaDescriptor
document *document.Node
diagnostics []Diagnostic
}
// Error reports a value-free application error at path. Related paths are
// resolved to source locations when possible.
func (v *Validation) Error(path Path, message string, related ...Path) {
v.report(SeverityError, path, message, related)
}
// Warning reports a value-free application warning at path. Related paths are
// resolved to source locations when possible.
func (v *Validation) Warning(path Path, message string, related ...Path) {
v.report(SeverityWarning, path, message, related)
}
func (v *Validation) report(severity Severity, path Path, message string, related []Path) {
if v == nil || v.state == nil || !v.state.active {
panic(closedCallbackContextMessage)
}
code := v.state.errorCode
if severity == SeverityWarning {
code = v.state.warningCode
}
diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message}
if !path.valid() {
invalidCode := v.state.invalidPath
if invalidCode == "" {
invalidCode = v.state.errorCode
}
diagnostic.Severity = SeverityError
diagnostic.Code = invalidCode
diagnostic.Path = Path{}
diagnostic.Message = "application reported an invalid configuration path"
} else if v.state.schema != nil {
if _, ok := resolveSchemaPath(v.state.schema, path); !ok {
diagnostic.Severity = SeverityError
diagnostic.Code = v.state.invalidPath
diagnostic.Message = "application reported a path outside the configuration schema"
v.state.diagnostics = append(v.state.diagnostics, diagnostic)
return
}
if location, ok := nearestDocumentLocation(v.state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
} else if location, ok := nearestDocumentLocation(v.state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
for _, relatedPath := range related {
if !relatedPath.valid() {
invalidCode := v.state.invalidPath
if invalidCode == "" {
invalidCode = v.state.errorCode
}
v.state.diagnostics = append(v.state.diagnostics, Diagnostic{
Code: invalidCode, Severity: SeverityError,
Message: "application reported an invalid related configuration path",
})
continue
}
if v.state.schema != nil {
if _, ok := resolveSchemaPath(v.state.schema, relatedPath); !ok {
v.state.diagnostics = append(v.state.diagnostics, Diagnostic{
Code: v.state.invalidPath, Severity: SeverityError, Path: relatedPath.clone(),
Message: "application reported a related path outside the configuration schema",
})
continue
}
}
if location, ok := nearestDocumentLocation(v.state.document, relatedPath); ok {
diagnostic.Related = append(diagnostic.Related, Location{
SourceID: location.SourceID, Line: location.Line, Column: location.Column,
})
}
}
v.state.diagnostics = append(v.state.diagnostics, diagnostic)
}
func runValidation[T any](configuration *T, validator func(*T, *Validation), schema *schemaDescriptor, before, input *document.Node, lookups map[string]secretLookupResult) []Diagnostic {
state := &validationState{
active: true, errorCode: CodeValidateError, warningCode: CodeValidateWarning,
invalidPath: CodeValidateInvalidPath, schema: schema, document: input,
}
context := &Validation{state: state}
validator(configuration, context)
state.active = false
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
if issue != nil {
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))
}
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
}
return canonicalDiagnostics(state.diagnostics)
}
func validationMutationDiagnostic(path Path) Diagnostic {
return Diagnostic{
Code: CodeValidateMutation, Severity: SeverityError, Path: path.clone(),
Message: "application validation mutated the configuration",
}
}
func nearestDocumentLocation(root *document.Node, path Path) (document.Location, bool) {
if root == nil || !path.valid() {
return document.Location{}, false
}
current := root
nearest := current.Location
for _, segment := range path.segments {
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
return nearest, locationKnown(nearest)
}
found := false
for _, entry := range current.Mapping {
if entry.Key == segment.key {
current = entry.Value
nearest = current.Location
found = true
break
}
}
if !found {
return nearest, locationKnown(nearest)
}
case document.Sequence:
index, ok := schemaIndex(segment)
if !ok || index >= len(current.Sequence) {
return nearest, locationKnown(nearest)
}
current = current.Sequence[index]
nearest = current.Location
default:
return nearest, locationKnown(nearest)
}
}
return nearest, locationKnown(nearest)
}
func locationKnown(location document.Location) bool {
return location.SourceID != "" || location.Line != 0 || location.Column != 0
}

214
validation_test.go Normal file
View File

@@ -0,0 +1,214 @@
package configkit
import (
"errors"
"reflect"
"testing"
)
func TestValidationCollectsSchemaAwareReportsAndNearestLocations(t *testing.T) {
t.Parallel()
type config struct {
Primary string `yaml:"primary"`
Missing string `yaml:"missing"`
Dynamic map[string]any `yaml:"dynamic"`
}
result, err := Load(Bytes("validation.yml", []byte("primary: configured\ndynamic: {present: 1}\n")), Options[config]{Definition: Definition[config]{
Validate: func(_ *config, validation *Validation) {
validation.Error(Keys("missing"), "is required", Keys("primary"))
validation.Error(Keys("dynamic", "missing"), "dynamic entry is required")
validation.Warning(Keys("primary"), "primary value is deprecated")
},
}})
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 3 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
byPath := make(map[string]Diagnostic)
for _, diagnostic := range diagnostics {
byPath[diagnostic.Path.String()] = diagnostic
}
missing := byPath["/missing"]
if missing.Code != CodeValidateError || missing.Primary == nil || missing.Primary.Line != 1 || len(missing.Related) != 1 || missing.Related[0].Line != 1 {
t.Fatalf("missing diagnostic = %#v", missing)
}
dynamic := byPath["/dynamic/missing"]
if dynamic.Code != CodeValidateError || dynamic.Primary == nil || dynamic.Primary.Line != 2 {
t.Fatalf("dynamic diagnostic = %#v", dynamic)
}
if warning := byPath["/primary"]; warning.Code != CodeValidateWarning || warning.Severity != SeverityWarning {
t.Fatalf("warning = %#v", warning)
}
if len(loadError.Sources()) != 1 {
t.Fatalf("sources = %#v", loadError.Sources())
}
}
func TestValidationRejectsUnknownStructPaths(t *testing.T) {
t.Parallel()
type config struct {
Known string `yaml:"known"`
}
result, err := Load(Bytes("validation-path.yml", []byte("known: value\n")), Options[config]{Definition: Definition[config]{
Validate: func(_ *config, validation *Validation) {
validation.Error(Keys("unknown"), "must be configured")
},
}})
assertLoadDiagnostic(t, result, err, CodeValidateInvalidPath)
}
func TestValidationMutationFailsWithoutPublishingResult(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
Items map[string]int `yaml:"items"`
}
result, err := Load(Bytes("mutation.yml", []byte("value: 1\nitems: {one: 1}\n")), Options[config]{Definition: Definition[config]{
Validate: func(configuration *config, _ *Validation) {
configuration.Value = 2
configuration.Items["one"] = 2
},
}})
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatal(err)
}
var paths []string
for _, diagnostic := range loadError.Diagnostics() {
if diagnostic.Code != CodeValidateMutation || diagnostic.Message != "application validation mutated the configuration" {
t.Fatalf("diagnostic = %#v", diagnostic)
}
paths = append(paths, diagnostic.Path.String())
}
if !reflect.DeepEqual(paths, []string{"/items/one", "/value"}) {
t.Fatalf("mutation paths = %#v", paths)
}
}
func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
t.Parallel()
type config struct {
Version string `yaml:"version"`
Value int `yaml:"value"`
}
t.Run("warnings continue", func(t *testing.T) {
var events []string
definition := Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
events = append(events, "version")
validation.Warning(Keys("version"), "version warning")
return nil
}},
Defaults: func() config {
events = append(events, "defaults")
return config{Value: 1}
},
Resolve: func(_ *config, resolution *Resolution) error {
events = append(events, "resolve")
resolution.Warning(Keys("value"), "resolution warning")
return nil
},
Validate: func(_ *config, validation *Validation) {
events = append(events, "validate")
validation.Warning(Keys("value"), "validation warning")
},
}
result, err := Load(Bytes("order.yml", []byte("version: one\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(events, []string{"version", "defaults", "resolve", "validate"}) || len(result.Diagnostics()) != 3 {
t.Fatalf("events = %#v, diagnostics = %#v", events, result.Diagnostics())
}
})
t.Run("version error stops later hooks", func(t *testing.T) {
var events []string
definition := Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
events = append(events, "version")
validation.Error(Keys("version"), "unsupported version")
return nil
}},
Defaults: func() config { events = append(events, "defaults"); return config{} },
Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil },
Validate: func(_ *config, _ *Validation) { events = append(events, "validate") },
}
result, err := Load(Bytes("order-error.yml", []byte("version: one\n")), Options[config]{Definition: definition})
assertLoadDiagnostic(t, result, err, CodeVersionError)
if !reflect.DeepEqual(events, []string{"version"}) {
t.Fatalf("events = %#v", events)
}
})
t.Run("resolution error stops validation", func(t *testing.T) {
var validated bool
result, err := Load(Bytes("resolution-order.yml", []byte("value: 1\nversion: one\n")), Options[config]{Definition: Definition[config]{
Resolve: func(_ *config, resolution *Resolution) error {
resolution.Error(Keys("value"), "cannot resolve")
return nil
},
Validate: func(_ *config, _ *Validation) { validated = true },
}})
assertLoadDiagnostic(t, result, err, CodeResolveError)
if validated {
t.Fatal("validation ran after resolution error")
}
})
t.Run("decode error stops application hooks", func(t *testing.T) {
var resolved, validated bool
result, err := Load(Bytes("decode-order.yml", []byte("value: wrong\nversion: one\n")), Options[config]{Definition: Definition[config]{
Resolve: func(_ *config, _ *Resolution) error { resolved = true; return nil },
Validate: func(_ *config, _ *Validation) { validated = true },
}})
assertLoadDiagnostic(t, result, err, CodeDecodeType)
if resolved || validated {
t.Fatalf("later hooks ran: resolve=%v validate=%v", resolved, validated)
}
})
}
func TestCompleteSingleSourceLifecycleKeepsMetadataAgreement(t *testing.T) {
t.Parallel()
type config struct {
Input int `yaml:"input"`
Derived int `yaml:"derived"`
}
result, err := Load(Bytes("complete.yml", []byte("input: 3\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Derived: 1} },
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input"))
return nil
},
Validate: func(configuration *config, validation *Validation) {
if configuration.Derived != 6 {
validation.Error(Keys("derived"), "derived value is inconsistent", Keys("input"))
}
},
}})
if err != nil {
t.Fatal(err)
}
input, _ := result.Metadata().Lookup(Keys("input"))
derived, _ := result.Metadata().Lookup(Keys("derived"))
history := result.Metadata().History(Keys("derived"))
if result.Config != (config{Input: 3, Derived: 6}) || !input.Explicit || input.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault || history[len(history)-1].Dependencies[0].String() != "/input" {
t.Fatalf("config/metadata = %#v / %#v / %#v / %#v", result.Config, input, derived, history)
}
}