Add snapshotted mapping configuration sources

This commit is contained in:
2026-09-29 17:43:19 +00:00
parent 1ff3c812a7
commit 90c6e8ed7e
12 changed files with 454 additions and 159 deletions

View File

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

View File

@@ -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() {

View File

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

View File

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

View File

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

View File

@@ -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}
@@ -163,14 +189,24 @@ func defaultSourceLimits() sourceLimits {
}
type sourceRead struct {
data []byte
info *SourceInfo
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):

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