Add snapshotted mapping configuration sources
This commit is contained in:
@@ -15,11 +15,18 @@ ConfigKit exposes opaque sources through these constructors:
|
||||
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
|
||||
| `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. |
|
||||
| `Mapping(label, values)` | Requires a safe nonempty label and a non-nil root `map[string]any`. Snapshots supported built-in values into an owned mapping document at construction; `{}` is valid. Constructor conversion failures become safe deferred diagnostics. |
|
||||
| `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 and filesystem sources can observe changes between consumptions; byte sources cannot.
|
||||
File and filesystem sources can observe changes between consumptions; byte and
|
||||
mapping sources cannot. Mapping accepts nil, built-in string, boolean, signed or
|
||||
unsigned integer, finite float, `[]any`, and `map[string]any` values recursively.
|
||||
Typed nil maps and slices represent explicit null; empty collections remain empty.
|
||||
Keys are sorted for stable provenance, integer decimal spelling stays exact, and
|
||||
cycles or unsupported types are rejected without exposing values. Mapping input
|
||||
uses the same directive and version rules as YAML, not CLI override restrictions.
|
||||
|
||||
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
||||
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||
@@ -27,6 +34,8 @@ 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.
|
||||
For mapping sources, key and string bytes use the same accounting as programmatic
|
||||
overrides, and the converted tree uses the same node and depth bounds.
|
||||
|
||||
## Single-source loading
|
||||
|
||||
@@ -51,7 +60,7 @@ validate options → parse the selected source → traverse/compose enabled impo
|
||||
→ snapshot configuration and metadata
|
||||
```
|
||||
|
||||
`File`, `Filesystem.File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
|
||||
`File`, `Filesystem.File`, `Bytes`, `Reader`, and `Mapping` 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
|
||||
@@ -68,7 +77,7 @@ 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. Each source
|
||||
record's `Kind` identifies file, filesystem, bytes, reader, or CLI acquisition
|
||||
record's `Kind` identifies file, filesystem, bytes, reader, mapping, or CLI acquisition
|
||||
independently of its precedence `Layer`. Host file paths remain canonical;
|
||||
filesystem paths are slash-relative within their namespace; synthetic inputs have
|
||||
no path. Mutating
|
||||
@@ -590,12 +599,12 @@ their contents are accumulated.
|
||||
|
||||
For host files, the configuration root defaults to the selected file's canonical
|
||||
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||
may not supply `BaseDir` or a native `FS`. Byte and reader inputs with host context
|
||||
may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context
|
||||
must provide both `BaseDir` and `Root` as absolute existing directories.
|
||||
For `Filesystem.File`, the default root is `.` in its namespace and the base is
|
||||
the selected file's directory. An explicit context must use the same `Filesystem`
|
||||
namespace and may set `Root` to contain the file and its base; it may not supply
|
||||
`BaseDir`. Byte and reader inputs with native context provide both `BaseDir` and
|
||||
`BaseDir`. Byte, reader, and mapping inputs with native context provide both `BaseDir` and
|
||||
`Root` as slash-relative `fs.ValidPath` directories in `FS` (`.` means its root),
|
||||
with the base inside the root. Context paths are checked even when imports and
|
||||
overlays are inactive. Diagnostic labels, including URI-looking labels, never
|
||||
|
||||
@@ -336,6 +336,16 @@ their contracts. Run the everyday checker, including public-boundary tests.
|
||||
|
||||
## Stage 5: Add snapshotted mapping sources
|
||||
|
||||
**Completed 2026-09-29.** `Mapping` now snapshots supported built-in values at
|
||||
construction, retaining safe deferred diagnostics for invalid roots, values,
|
||||
cycles, nonfinite floats, and limits. A shared private converter preserves CLI
|
||||
override behavior through a separate adapter. Mapping inputs participate in
|
||||
ordinary directives, imports, version checks, decoding, and inspection with
|
||||
their own source kind and synthetic locations. Public tests cover YAML
|
||||
equivalence, reuse after caller mutation, null/empty/zero distinctions, exact
|
||||
integers, explicit filesystem context, and safe failures. The everyday checker
|
||||
and engine race suite passed.
|
||||
|
||||
1. Extract reusable value conversion from `override_value.go`; keep CLI-specific
|
||||
path, label, and diagnostic handling in its adapter.
|
||||
2. Implement `Mapping` at root and engine boundaries with constructor-time
|
||||
|
||||
133
facade_mapping_test.go
Normal file
133
facade_mapping_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type mappingFacadeConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Count uint64 `yaml:"count"`
|
||||
Optional *string `yaml:"optional"`
|
||||
Items []string `yaml:"items"`
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
|
||||
items := []any{"one", "two"}
|
||||
values := map[string]any{
|
||||
"name": "original", "enabled": false, "count": ^uint64(0),
|
||||
"optional": nil, "items": items,
|
||||
}
|
||||
source := configkit.Mapping("programmatic", values)
|
||||
values["name"] = "mutated"
|
||||
items[0] = "mutated"
|
||||
for range 2 {
|
||||
result, err := configkit.Load[mappingFacadeConfig](source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := mappingFacadeConfig{Count: ^uint64(0), Items: []string{"one", "two"}, Name: "original"}
|
||||
if !reflect.DeepEqual(result.Config, want) {
|
||||
t.Fatalf("mapping config = %#v, want %#v", result.Config, want)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerBase || sources[0].Path != "" {
|
||||
t.Fatalf("mapping sources = %#v", sources)
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys("name"))
|
||||
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 0 {
|
||||
t.Fatalf("mapping name origin = %#v, %v", info, ok)
|
||||
}
|
||||
fromYAML, err := configkit.Load[mappingFacadeConfig](configkit.Bytes("equivalent", []byte("enabled: false\ncount: 18446744073709551615\noptional: null\nitems: [one, two]\nname: original\n")))
|
||||
if err != nil || !reflect.DeepEqual(result.Config, fromYAML.Config) {
|
||||
t.Fatalf("equivalent YAML = %#v, %v", fromYAML, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeMappingNullEmptyAndVersion(t *testing.T) {
|
||||
result, err := configkit.Load[mappingFacadeConfig](configkit.Mapping("empty", map[string]any{}))
|
||||
if err != nil || len(result.Sources()) != 1 {
|
||||
t.Fatalf("empty mapping = %#v, %v", result, err)
|
||||
}
|
||||
var nilSequence []any
|
||||
var nilMap map[string]any
|
||||
var checked string
|
||||
result, err = configkit.Load(configkit.Mapping("nullable", map[string]any{
|
||||
"items": nilSequence, "optional": nilMap, "enabled": false, "count": uint64(0),
|
||||
}), configkit.Options[mappingFacadeConfig]{Definition: configkit.Definition[mappingFacadeConfig]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("count"), Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||
checked = version.Text
|
||||
return nil
|
||||
}},
|
||||
}})
|
||||
if err != nil || checked != "0" || result.Config.Items != nil || result.Config.Optional != nil {
|
||||
t.Fatalf("null and zero mapping = %#v, checked %q, %v", result, checked, err)
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys("items"))
|
||||
if !ok || !info.Null || !info.Explicit {
|
||||
t.Fatalf("null metadata = %#v, %v", info, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
|
||||
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"root/shared.yml": &fstest.MapFile{Data: []byte("name: imported\n")},
|
||||
})
|
||||
source := configkit.Mapping("programmatic", map[string]any{
|
||||
"configkit": map[string]any{"imports": []any{map[string]any{"path": "shared.yml"}}},
|
||||
"enabled": true,
|
||||
})
|
||||
result, err := configkit.Load(source, configkit.Options[mappingFacadeConfig]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &namespace},
|
||||
})
|
||||
if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
|
||||
t.Fatalf("mapping import = %#v, %v", result, err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerBase {
|
||||
t.Fatalf("mapping import sources = %#v", sources)
|
||||
}
|
||||
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)
|
||||
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{}, configkit.CodeImportDisabled)
|
||||
}
|
||||
|
||||
func TestFacadeMappingDeferredValueDiagnostics(t *testing.T) {
|
||||
cycle := map[string]any{}
|
||||
cycle["secret"] = cycle
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source configkit.Source
|
||||
want configkit.Code
|
||||
path string
|
||||
}{
|
||||
{"nil root", configkit.Mapping("nil", nil), configkit.CodeSourceInvalid, ""},
|
||||
{"bad label", configkit.Mapping("bad\nlabel", map[string]any{}), configkit.CodeSourceInvalid, ""},
|
||||
{"unsupported", configkit.Mapping("bad", map[string]any{"secret": struct{ Value string }{"canary-value"}}), configkit.CodeSourceInvalid, "/secret"},
|
||||
{"nonfinite", configkit.Mapping("bad", map[string]any{"count": math.NaN()}), configkit.CodeSourceInvalid, "/count"},
|
||||
{"cycle", configkit.Mapping("bad", cycle), configkit.CodeSourceInvalid, "/secret"},
|
||||
{"too many bytes", configkit.Mapping("bad", map[string]any{"name": strings.Repeat("x", 8<<20+1)}), configkit.CodeSourceLimitExceeded, "/name"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
loadError := checkMappingCode(t, test.source, configkit.Options[mappingFacadeConfig]{}, test.want)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Path.String() != test.path || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || strings.Contains(diagnostic.Message, "canary-value") {
|
||||
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkMappingCode(t *testing.T, source configkit.Source, options configkit.Options[mappingFacadeConfig], want configkit.Code) *configkit.LoadError {
|
||||
t.Helper()
|
||||
_, err := configkit.Load(source, options)
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != want {
|
||||
t.Fatalf("error = %#v, want %s", err, want)
|
||||
}
|
||||
return loadError
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
|
||||
if context.Root != "" && !fs.ValidPath(context.Root) {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem root must be a slash-relative valid path")}
|
||||
}
|
||||
case sourceBytes, sourceReader:
|
||||
case sourceBytes, sourceReader, sourceMapping:
|
||||
if context.BaseDir == "" || context.Root == "" {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
||||
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
|
||||
}
|
||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeOverlay(options.Overlay)) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or overlays require filesystem context"))
|
||||
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader || source.kind == sourceMapping) && (options.Imports || activeOverlay(options.Overlay)) {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
|
||||
}
|
||||
if definition.Version != nil {
|
||||
if !definition.Version.Path.valid() {
|
||||
|
||||
@@ -94,11 +94,12 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
|
||||
}
|
||||
pathAccepted[index] = true
|
||||
|
||||
builder := overrideValueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)}
|
||||
builder := valueBuilder{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)
|
||||
diagnostic := issue.overrideDiagnostic()
|
||||
diagnostic.Primary = location
|
||||
diagnostics = append(diagnostics, diagnostic)
|
||||
continue
|
||||
}
|
||||
if relative, ok := overrideDirectivePath(value, Path{}); ok {
|
||||
|
||||
@@ -1,150 +1,21 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type overrideValueBuilder struct {
|
||||
sourceID string
|
||||
active map[valueVisit]bool
|
||||
bytes int64
|
||||
nodes int
|
||||
}
|
||||
|
||||
func (b *overrideValueBuilder) build(value any, path Path, depth int) (*document.Node, *Diagnostic) {
|
||||
if depth > maxDocumentDepth {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the depth limit")
|
||||
}
|
||||
b.nodes++
|
||||
if b.nodes > maxDocumentNodes {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
location := document.Location{SourceID: b.sourceID}
|
||||
switch value := value.(type) {
|
||||
case nil:
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
case string:
|
||||
if !b.addBytes(len(value)) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the record string-byte limit")
|
||||
}
|
||||
return &document.Node{Kind: document.String, Text: value, Location: location}, nil
|
||||
case bool:
|
||||
return &document.Node{Kind: document.Boolean, Text: strconv.FormatBool(value), Location: location}, nil
|
||||
case int:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int8:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int16:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int32:
|
||||
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int64:
|
||||
return overrideNumber(strconv.FormatInt(value, 10), location), nil
|
||||
case uint:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint8:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint16:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint32:
|
||||
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint64:
|
||||
return overrideNumber(strconv.FormatUint(value, 10), location), nil
|
||||
case float32:
|
||||
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
|
||||
}
|
||||
return overrideNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil
|
||||
case float64:
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
|
||||
}
|
||||
return overrideNumber(strconv.FormatFloat(value, 'g', -1, 64), location), nil
|
||||
case []any:
|
||||
if value == nil {
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
}
|
||||
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
defer leave()
|
||||
if len(value) > maxDocumentNodes-b.nodes {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
node := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, len(value))}
|
||||
for index, child := range value {
|
||||
built, diagnostic := b.build(child, path.Index(index), depth+1)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
node.Sequence[index] = built
|
||||
}
|
||||
return node, nil
|
||||
case map[string]any:
|
||||
if value == nil {
|
||||
return &document.Node{Kind: document.Null, Location: location}, nil
|
||||
}
|
||||
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
defer leave()
|
||||
if len(value) > (maxDocumentNodes-b.nodes)/2 {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
|
||||
}
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
node := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(keys))}
|
||||
for _, key := range keys {
|
||||
b.nodes++ // mapping key
|
||||
if !b.addBytes(len(key)) {
|
||||
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path.Key(key), "configuration override exceeds the record string-byte limit")
|
||||
}
|
||||
built, diagnostic := b.build(value[key], path.Key(key), depth+1)
|
||||
if diagnostic != nil {
|
||||
return nil, diagnostic
|
||||
}
|
||||
node.Mapping = append(node.Mapping, document.Entry{Key: key, KeyLocation: location, ValueLocation: location, Value: built})
|
||||
}
|
||||
return node, nil
|
||||
func (issue *valueIssue) overrideDiagnostic() Diagnostic {
|
||||
code := CodeOverrideInvalidValue
|
||||
var message string
|
||||
switch issue.kind {
|
||||
case valueDepthLimit:
|
||||
code, message = CodeOverrideLimitExceeded, "configuration override exceeds the depth limit"
|
||||
case valueNodeLimit:
|
||||
code, message = CodeOverrideLimitExceeded, "configuration override exceeds the node limit"
|
||||
case valueByteLimit:
|
||||
code, message = CodeOverrideLimitExceeded, "configuration override exceeds the record string-byte limit"
|
||||
case valueNonFinite:
|
||||
message = "configuration override float must be finite"
|
||||
case valueCycle:
|
||||
message = "configuration override values must not contain cycles"
|
||||
default:
|
||||
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override value has an unsupported type")
|
||||
message = "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}
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Path: issue.path.clone(), Message: message}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ func parseSource(source Source, sourceID string, limits parseLimits) (parsedSour
|
||||
if len(diagnostics) != 0 || read.info == nil {
|
||||
return parsedSource{source: read.info}, diagnostics
|
||||
}
|
||||
if read.document != nil {
|
||||
return parsedSource{document: read.document, source: read.info, bytes: read.bytes}, nil
|
||||
}
|
||||
|
||||
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
|
||||
if len(issues) != 0 {
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,6 +27,10 @@ type Source struct {
|
||||
namespace *nativeNamespace
|
||||
data []byte
|
||||
reader *readerSource
|
||||
mapping *document.Node
|
||||
mapIssue *valueIssue
|
||||
mapBytes int64
|
||||
mapNodes int
|
||||
createErr sourceCreationError
|
||||
}
|
||||
|
||||
@@ -36,6 +42,7 @@ const (
|
||||
sourceFilesystem
|
||||
sourceBytes
|
||||
sourceReader
|
||||
sourceMapping
|
||||
sourceNone
|
||||
)
|
||||
|
||||
@@ -50,6 +57,7 @@ const (
|
||||
creationInvalidFilesystem
|
||||
creationInvalidFilesystemLabel
|
||||
creationInvalidFilesystemPath
|
||||
creationInvalidMappingRoot
|
||||
)
|
||||
|
||||
// Filesystem is a reusable opaque namespace for caller-owned fs.FS inputs.
|
||||
@@ -148,6 +156,24 @@ func Reader(label string, input io.Reader) Source {
|
||||
return source
|
||||
}
|
||||
|
||||
// Mapping snapshots supported built-in values into an owned document at
|
||||
// construction. Invalid values are retained as safe deferred diagnostics.
|
||||
func Mapping(label string, values map[string]any) Source {
|
||||
source := Source{kind: sourceMapping, label: label}
|
||||
if !validOverrideLabel(label) {
|
||||
source.createErr = creationInvalidLabel
|
||||
return source
|
||||
}
|
||||
if values == nil {
|
||||
source.createErr = creationInvalidMappingRoot
|
||||
return source
|
||||
}
|
||||
builder := valueBuilder{active: make(map[valueVisit]bool)}
|
||||
source.mapping, source.mapIssue = builder.build(values, Path{}, 0)
|
||||
source.mapBytes, source.mapNodes = builder.bytes, builder.nodes
|
||||
return source
|
||||
}
|
||||
|
||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
||||
func NoSource() Source {
|
||||
return Source{kind: sourceNone}
|
||||
@@ -164,13 +190,23 @@ func defaultSourceLimits() sourceLimits {
|
||||
|
||||
type sourceRead struct {
|
||||
data []byte
|
||||
document *document.Node
|
||||
bytes int64
|
||||
info *SourceInfo
|
||||
}
|
||||
|
||||
func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead, []Diagnostic) {
|
||||
if diagnostic := sourceConstructorDiagnostic(source); diagnostic != nil {
|
||||
if source.kind == sourceMapping {
|
||||
diagnostic.Primary = &Location{SourceID: sourceID}
|
||||
return sourceRead{info: mappingSourceInfo(source, sourceID)}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
return sourceRead{}, []Diagnostic{*diagnostic}
|
||||
}
|
||||
if source.kind == sourceMapping && source.mapIssue != nil {
|
||||
diagnostic := source.mapIssue.sourceDiagnostic(sourceID)
|
||||
return sourceRead{info: mappingSourceInfo(source, sourceID)}, []Diagnostic{diagnostic}
|
||||
}
|
||||
if source.kind == sourceNone {
|
||||
return sourceRead{}, nil
|
||||
}
|
||||
@@ -219,6 +255,13 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceReaderUsed, sourceID, "reader source has already been consumed")}
|
||||
}
|
||||
input = source.reader.input
|
||||
case sourceMapping:
|
||||
if source.mapBytes > limit || source.mapNodes > maxDocumentNodes {
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "mapping source exceeds the configured input limit")}
|
||||
}
|
||||
owned := document.Clone(source.mapping)
|
||||
setDocumentSourceID(owned, sourceID)
|
||||
return sourceRead{document: owned, bytes: source.mapBytes, info: info}, nil
|
||||
default:
|
||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
||||
}
|
||||
@@ -229,6 +272,14 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||
}
|
||||
|
||||
func mappingSourceInfo(source Source, sourceID string) *SourceInfo {
|
||||
label := source.label
|
||||
if !validOverrideLabel(label) {
|
||||
label = "mapping"
|
||||
}
|
||||
return &SourceInfo{ID: sourceID, Label: label, Layer: LayerBase, Kind: SourceKindMapping}
|
||||
}
|
||||
|
||||
func sourceConstructorDiagnostic(source Source) *Diagnostic {
|
||||
var message string
|
||||
code := CodeSourceInvalid
|
||||
@@ -242,6 +293,9 @@ func sourceConstructorDiagnostic(source Source) *Diagnostic {
|
||||
message = "source path must not be empty"
|
||||
case creationInvalidLabel:
|
||||
message = "source label must not be empty"
|
||||
if source.kind == sourceMapping {
|
||||
message = "mapping source label must be nonempty and safe to display"
|
||||
}
|
||||
case creationInvalidReader:
|
||||
message = "source reader must not be nil"
|
||||
case creationTooLarge:
|
||||
@@ -253,6 +307,8 @@ func sourceConstructorDiagnostic(source Source) *Diagnostic {
|
||||
message = "filesystem label must be nonempty and safe to display"
|
||||
case creationInvalidFilesystemPath:
|
||||
message = "filesystem source path must be a non-root slash-relative valid path"
|
||||
case creationInvalidMappingRoot:
|
||||
message = "mapping source root must be a non-nil mapping"
|
||||
}
|
||||
diagnostic := sourceDiagnostic(code, "", message)
|
||||
return &diagnostic
|
||||
@@ -268,11 +324,29 @@ func sourceSourceKind(kind sourceKind) SourceKind {
|
||||
return SourceKindBytes
|
||||
case sourceReader:
|
||||
return SourceKindReader
|
||||
case sourceMapping:
|
||||
return SourceKindMapping
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func setDocumentSourceID(node *document.Node, sourceID string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
node.Location.SourceID = sourceID
|
||||
for index := range node.Mapping {
|
||||
entry := &node.Mapping[index]
|
||||
entry.KeyLocation.SourceID = sourceID
|
||||
entry.ValueLocation.SourceID = sourceID
|
||||
setDocumentSourceID(entry.Value, sourceID)
|
||||
}
|
||||
for _, child := range node.Sequence {
|
||||
setDocumentSourceID(child, sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func classifySourceError(err error, sourceID string) Diagnostic {
|
||||
switch {
|
||||
case errors.Is(err, fs.ErrNotExist):
|
||||
|
||||
19
internal/engine/source_value.go
Normal file
19
internal/engine/source_value.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package engine
|
||||
|
||||
func (issue *valueIssue) sourceDiagnostic(sourceID string) Diagnostic {
|
||||
code := CodeSourceInvalid
|
||||
message := "mapping source has an unsupported value"
|
||||
switch issue.kind {
|
||||
case valueDepthLimit:
|
||||
code, message = CodeSourceLimitExceeded, "mapping source exceeds the depth limit"
|
||||
case valueNodeLimit:
|
||||
code, message = CodeSourceLimitExceeded, "mapping source exceeds the node limit"
|
||||
case valueByteLimit:
|
||||
code, message = CodeSourceLimitExceeded, "mapping source exceeds the string-byte limit"
|
||||
case valueNonFinite:
|
||||
message = "mapping source float must be finite"
|
||||
case valueCycle:
|
||||
message = "mapping source values must not contain cycles"
|
||||
}
|
||||
return Diagnostic{Code: code, Severity: SeverityError, Path: issue.path.clone(), Message: message, Primary: &Location{SourceID: sourceID}}
|
||||
}
|
||||
166
internal/engine/value.go
Normal file
166
internal/engine/value.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type valueBuilder struct {
|
||||
sourceID string
|
||||
active map[valueVisit]bool
|
||||
bytes int64
|
||||
nodes int
|
||||
}
|
||||
|
||||
type valueIssueKind uint8
|
||||
|
||||
const (
|
||||
valueDepthLimit valueIssueKind = iota
|
||||
valueNodeLimit
|
||||
valueByteLimit
|
||||
valueNonFinite
|
||||
valueUnsupported
|
||||
valueCycle
|
||||
)
|
||||
|
||||
type valueIssue struct {
|
||||
kind valueIssueKind
|
||||
path Path
|
||||
}
|
||||
|
||||
func newValueIssue(kind valueIssueKind, path Path) *valueIssue {
|
||||
return &valueIssue{kind: kind, path: path.clone()}
|
||||
}
|
||||
|
||||
func (b *valueBuilder) build(value any, path Path, depth int) (*document.Node, *valueIssue) {
|
||||
if depth > maxDocumentDepth {
|
||||
return nil, newValueIssue(valueDepthLimit, path)
|
||||
}
|
||||
b.nodes++
|
||||
if b.nodes > maxDocumentNodes {
|
||||
return nil, newValueIssue(valueNodeLimit, path)
|
||||
}
|
||||
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, newValueIssue(valueByteLimit, path)
|
||||
}
|
||||
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 valueNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int8:
|
||||
return valueNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int16:
|
||||
return valueNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int32:
|
||||
return valueNumber(strconv.FormatInt(int64(value), 10), location), nil
|
||||
case int64:
|
||||
return valueNumber(strconv.FormatInt(value, 10), location), nil
|
||||
case uint:
|
||||
return valueNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint8:
|
||||
return valueNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint16:
|
||||
return valueNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint32:
|
||||
return valueNumber(strconv.FormatUint(uint64(value), 10), location), nil
|
||||
case uint64:
|
||||
return valueNumber(strconv.FormatUint(value, 10), location), nil
|
||||
case float32:
|
||||
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
|
||||
return nil, newValueIssue(valueNonFinite, path)
|
||||
}
|
||||
return valueNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil
|
||||
case float64:
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return nil, newValueIssue(valueNonFinite, path)
|
||||
}
|
||||
return valueNumber(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, newValueIssue(valueNodeLimit, path)
|
||||
}
|
||||
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, newValueIssue(valueNodeLimit, path)
|
||||
}
|
||||
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, newValueIssue(valueByteLimit, path.Key(key))
|
||||
}
|
||||
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, newValueIssue(valueUnsupported, path)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *valueBuilder) addBytes(count int) bool {
|
||||
if count < 0 || int64(count) > maxSourceBytes-b.bytes {
|
||||
return false
|
||||
}
|
||||
b.bytes += int64(count)
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *valueBuilder) enter(value reflect.Value, path Path) (func(), *valueIssue) {
|
||||
visit, ok := visitFor(value)
|
||||
if !ok {
|
||||
return func() {}, nil
|
||||
}
|
||||
if b.active[visit] {
|
||||
return nil, newValueIssue(valueCycle, path)
|
||||
}
|
||||
b.active[visit] = true
|
||||
return func() { delete(b.active, visit) }, nil
|
||||
}
|
||||
|
||||
func valueNumber(text string, location document.Location) *document.Node {
|
||||
return &document.Node{Kind: document.Number, Text: text, Location: location}
|
||||
}
|
||||
11
source.go
11
source.go
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// Source is an opaque configuration input. Construct sources with File,
|
||||
// Filesystem.File, Bytes, Reader, or NoSource; the zero value is invalid.
|
||||
// Filesystem.File, Bytes, Reader, Mapping, or NoSource; the zero value is invalid.
|
||||
type Source struct{ value engine.Source }
|
||||
|
||||
// Filesystem is a reusable opaque namespace for a caller-owned fs.FS. Copies
|
||||
@@ -38,6 +38,15 @@ func Bytes(label string, data []byte) Source { return Source{value: engine.Bytes
|
||||
// Copies of the returned Source share the same single-use claim.
|
||||
func Reader(label string, input io.Reader) Source { return Source{value: engine.Reader(label, input)} }
|
||||
|
||||
// Mapping snapshots supported built-in values immediately. A nil root map is
|
||||
// invalid; an empty map is a valid empty document. Values may be nil, built-in
|
||||
// scalars, []any, or map[string]any recursively. Unsupported values and cycles
|
||||
// are reported when the source is consumed. Label must be nonempty and safe to
|
||||
// display. Later caller mutation cannot change the captured document.
|
||||
func Mapping(label string, values map[string]any) Source {
|
||||
return Source{value: engine.Mapping(label, values)}
|
||||
}
|
||||
|
||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
||||
func NoSource() Source { return Source{value: engine.NoSource()} }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user