v0.2.0 toml-only, harden concurrency and env export, dep updates
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 16:55:58 -04:00
1 parent 3c35601179
commit e08467bf38
42 files changed
+2533 -4274

No files matched your search

+297 -264
View File
@@ -1,309 +1,342 @@
// FILE: lixenwraith/config/decode.go
package config
import (
"encoding/json"
"fmt"
"net"
"net/url"
"reflect"
"strconv"
"strings"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/lixenwraith/toml"
)
// unmarshal is the single authoritative function for decoding configuration
// into target structures. All public decoding methods delegate to this
func (c *Config) unmarshal(source Source, target any, basePath ...string) error {
// Parse variadic basePath
if len(basePath) > 1 {
return wrapError(ErrInvalidPath, fmt.Errorf("expected at most one basePath"))
}
path := ""
switch len(basePath) {
case 0:
// Use default empty path
case 1:
path = basePath[0]
default:
return wrapError(ErrInvalidPath, fmt.Errorf("too many basePath arguments: expected 0 or 1, got %d", len(basePath)))
if len(basePath) == 1 {
path = strings.TrimSuffix(basePath[0], ".")
}
// Validate target
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return wrapError(ErrTypeMismatch, fmt.Errorf("unmarshal target must be non-nil pointer, got %T", target))
}
c.mutex.RLock()
defer c.mutex.RUnlock()
nested := c.nestedLocked(source)
c.mutex.RUnlock()
section := navigateToPath(nested, path)
if section == nil {
section = map[string]any{}
}
if reflect.TypeOf(section).Kind() != reflect.Map {
return wrapError(ErrTypeMismatch, fmt.Errorf("path %q refers to non-map value", path))
}
return decodeConfig(section, target)
}
// Build nested map based on source selection
nestedMap := make(map[string]any)
if source == "" {
// Use current merged state
for path, item := range c.items {
setNestedValue(nestedMap, path, item.currentValue)
func (c *Config) nestedLocked(source Source) map[string]any {
nested := make(map[string]any)
for path, item := range c.items {
var value any
var ok bool
switch source {
case "":
value, ok = item.currentValue, true
case SourceDefault:
value, ok = item.defaultValue, true
default:
value, ok = item.values[source]
}
} else {
// Use specific source
for path, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedMap, path, val)
}
if ok {
setNestedValue(nested, path, value)
}
}
return nested
}
// Navigate to basePath section
sectionData := navigateToPath(nestedMap, path)
// Ensure we have a map to decode, normalizing if necessary
sectionMap, err := normalizeMap(sectionData)
if err != nil {
if sectionData == nil {
sectionMap = make(map[string]any) // Empty section is valid
} else {
// Path points to a non-map value, which is an error for Scan
return wrapError(ErrTypeMismatch, fmt.Errorf("path %q refers to non-map value (type %T)", path, sectionData))
}
// decodeConfig stages writes and retains missing struct fields. It never mutates
// stored configuration or existing destination containers on a failed conversion.
func decodeConfig(data any, target any) error {
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
return wrapError(ErrTypeMismatch, fmt.Errorf("target must be non-nil pointer"))
}
// Create decoder with comprehensive hooks
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: c.tagName,
WeaklyTypedInput: true,
DecodeHook: c.getDecodeHook(),
ZeroFields: true,
Metadata: nil,
})
if err != nil {
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
next := reflect.New(rv.Elem().Type()).Elem()
next.Set(rv.Elem())
if err := decodeInto(data, next, 0); err != nil {
return wrapError(ErrDecode, err)
}
if err := decoder.Decode(sectionMap); err != nil {
return wrapError(ErrDecode, fmt.Errorf("decode failed for path %q: %w", path, err))
}
rv.Elem().Set(next)
return nil
}
// normalizeMap ensures that the input data is a map[string]any for the decoder
func decodeInto(data any, dst reflect.Value, depth int) error {
if depth > maxValueDepth {
return fmt.Errorf("decode nesting exceeds %d", maxValueDepth)
}
if data == nil {
dst.SetZero()
return nil
}
src := reflect.ValueOf(data)
for src.Kind() == reflect.Pointer || src.Kind() == reflect.Interface {
if src.IsNil() {
dst.SetZero()
return nil
}
src = src.Elem()
depth++
if depth > maxValueDepth {
return fmt.Errorf("cyclic pointer input")
}
}
data = src.Interface()
if dst.Kind() == reflect.Pointer {
next := reflect.New(dst.Type().Elem())
if !dst.IsNil() {
next.Elem().Set(dst.Elem())
}
if err := decodeInto(data, next.Elem(), depth+1); err != nil {
return err
}
dst.Set(next)
return nil
}
if atomicType(dst.Type()) && src.Type() == dst.Type() {
v, err := copyReflect(src, depth+1)
if err != nil {
return err
}
dst.Set(v)
return nil
}
if src.Kind() == reflect.String {
s := src.String()
if len(s) > MaxValueSize {
return ErrValueSize
}
switch dst.Type() {
case durationType:
v, err := time.ParseDuration(s)
if err != nil {
return err
}
dst.SetInt(int64(v))
return nil
case timeType:
v, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return err
}
dst.Set(reflect.ValueOf(v))
return nil
case ipType:
if len(s) > MaxIPv6Length {
return fmt.Errorf("invalid IP length: %d", len(s))
}
v := net.ParseIP(s)
if v == nil {
return fmt.Errorf("invalid IP address %q", s)
}
dst.Set(reflect.ValueOf(v))
return nil
case ipNetType:
if len(s) > MaxCIDRLength {
return fmt.Errorf("invalid CIDR length: %d", len(s))
}
_, v, err := net.ParseCIDR(s)
if err != nil {
return fmt.Errorf("invalid CIDR: %w", err)
}
dst.Set(reflect.ValueOf(*v))
return nil
case urlType:
if len(s) > MaxURLLength {
return fmt.Errorf("URL too long: %d bytes", len(s))
}
v, err := url.Parse(s)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
dst.Set(reflect.ValueOf(*v))
return nil
}
switch dst.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
v, err := strconv.ParseInt(s, 10, dst.Type().Bits())
if err != nil {
return err
}
dst.SetInt(v)
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
v, err := strconv.ParseUint(s, 10, dst.Type().Bits())
if err != nil {
return err
}
dst.SetUint(v)
return nil
case reflect.Float32, reflect.Float64:
v, err := strconv.ParseFloat(s, dst.Type().Bits())
if err != nil {
return err
}
return toml.Decode(v, dst.Addr().Interface())
case reflect.Bool:
v, err := strconv.ParseBool(s)
if err != nil {
return err
}
dst.SetBool(v)
return nil
case reflect.Slice, reflect.Array:
parts := []string{}
if s != "" {
parts = strings.Split(s, ",")
}
data, src = parts, reflect.ValueOf(parts)
}
}
switch dst.Kind() {
case reflect.Interface:
v, err := copyReflect(src, depth+1)
if err != nil {
return err
}
if !v.Type().AssignableTo(dst.Type()) {
return fmt.Errorf("cannot assign %s to %s", v.Type(), dst.Type())
}
dst.Set(v)
case reflect.Struct:
if atomicType(dst.Type()) {
return fmt.Errorf("cannot decode %T as %s", data, dst.Type())
}
m, err := normalizeMap(data)
if err != nil {
return err
}
seen := make(map[string]bool)
for i := 0; i < dst.NumField(); i++ {
f := dst.Type().Field(i)
if !f.IsExported() {
continue
}
key := fieldKey(f)
if key == "-" {
continue
}
if seen[key] {
return fmt.Errorf("duplicate TOML field %q", key)
}
seen[key] = true
if v, ok := m[key]; ok {
if err := decodeInto(v, dst.Field(i), depth+1); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
}
}
case reflect.Map:
if dst.Type().Key().Kind() != reflect.String {
return fmt.Errorf("map keys must be strings")
}
m, err := normalizeMap(data)
if err != nil {
return err
}
next := reflect.MakeMapWithSize(dst.Type(), len(m))
for key, value := range m {
v := reflect.New(dst.Type().Elem()).Elem()
if err := decodeInto(value, v, depth+1); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
next.SetMapIndex(reflect.ValueOf(key).Convert(dst.Type().Key()), v)
}
dst.Set(next)
case reflect.Slice, reflect.Array:
if src.Kind() != reflect.Slice && src.Kind() != reflect.Array {
return fmt.Errorf("expected array, got %T", data)
}
var next reflect.Value
if dst.Kind() == reflect.Array {
if dst.Len() != src.Len() {
return fmt.Errorf("array length %d does not match %d", src.Len(), dst.Len())
}
next = reflect.New(dst.Type()).Elem()
} else {
next = reflect.MakeSlice(dst.Type(), src.Len(), src.Len())
}
for i := 0; i < src.Len(); i++ {
if err := decodeInto(src.Index(i).Interface(), next.Index(i), depth+1); err != nil {
return fmt.Errorf("index %d: %w", i, err)
}
}
dst.Set(next)
case reflect.String:
switch src.Kind() {
case reflect.String:
dst.SetString(src.String())
case reflect.Bool:
dst.SetString(strconv.FormatBool(src.Bool()))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
dst.SetString(strconv.FormatInt(src.Int(), 10))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
dst.SetString(strconv.FormatUint(src.Uint(), 10))
case reflect.Float32, reflect.Float64:
if _, err := copyReflect(src, depth+1); err != nil {
return err
}
dst.SetString(strconv.FormatFloat(src.Float(), 'g', -1, src.Type().Bits()))
default:
return fmt.Errorf("cannot convert %T to string", data)
}
case reflect.Bool:
if src.Kind() != reflect.Bool {
return fmt.Errorf("cannot convert %T to bool", data)
}
dst.SetBool(src.Bool())
default:
return toml.Decode(data, dst.Addr().Interface())
}
return nil
}
func fieldKey(f reflect.StructField) string {
key, _, _ := strings.Cut(f.Tag.Get(FormatTOML), ",")
if key == "" {
key = f.Name
}
return key
}
func normalizeMap(data any) (map[string]any, error) {
if data == nil {
return make(map[string]any), nil
return map[string]any{}, nil
}
// If it's already the correct type, return it.
if m, ok := data.(map[string]any); ok {
return m, nil
}
// Use reflection to handle other map types (e.g., map[string]bool)
v := reflect.ValueOf(data)
if v.Kind() == reflect.Map {
if v.Type().Key().Kind() != reflect.String {
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("map keys must be strings, but got %v", v.Type().Key()))
}
// Create a new map[string]any and copy the values
normalized := make(map[string]any, v.Len())
iter := v.MapRange()
for iter.Next() {
normalized[iter.Key().String()] = iter.Value().Interface()
}
return normalized, nil
if v.Kind() != reflect.Map || v.Type().Key().Kind() != reflect.String {
return nil, fmt.Errorf("expected string-keyed map, got %T", data)
}
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("expected a map but got %T", data))
}
// getDecodeHook returns the composite decode hook for all type conversions
func (c *Config) getDecodeHook() mapstructure.DecodeHookFunc {
return mapstructure.ComposeDecodeHookFunc(
// JSON Number handling
jsonNumberHookFunc(),
// Network types
stringToNetIPHookFunc(),
stringToNetIPNetHookFunc(),
stringToURLHookFunc(),
// Standard hooks
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
// Custom application hooks
c.customDecodeHook(),
)
}
// jsonNumberHookFunc handles json.Number conversion to appropriate numeric types
func jsonNumberHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
// Check if source is json.Number
if f != reflect.TypeOf(json.Number("")) {
return data, nil
}
num := data.(json.Number)
// Convert based on target type
switch t.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
val, err := num.Int64()
if err != nil {
return nil, wrapError(ErrDecode, err)
}
return val, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
// Parse as int64 first, then convert
i, err := num.Int64()
if err != nil {
return nil, wrapError(ErrDecode, err)
}
if i < 0 {
return nil, wrapError(ErrDecode, fmt.Errorf("cannot convert negative number to unsigned type"))
}
return uint64(i), nil
case reflect.Float32, reflect.Float64:
val, err := num.Float64()
if err != nil {
return nil, wrapError(ErrDecode, err)
}
return val, nil
case reflect.String:
return num.String(), nil
default:
// Return as-is for other types
return data, nil
}
m := make(map[string]any, v.Len())
it := v.MapRange()
for it.Next() {
m[it.Key().String()] = it.Value().Interface()
}
return m, nil
}
// stringToNetIPHookFunc handles net.IP conversion
func stringToNetIPHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
if f.Kind() != reflect.String {
return data, nil
}
if t != reflect.TypeOf(net.IP{}) {
return data, nil
}
// SECURITY: Validate IP string format to prevent injection
str := data.(string)
if len(str) > MaxIPv6Length {
return nil, fmt.Errorf("invalid IP length: %d", len(str))
}
ip := net.ParseIP(str)
if ip == nil {
return nil, fmt.Errorf("invalid IP address: %s", str)
}
return ip, nil
}
}
// stringToNetIPNetHookFunc handles net.IPNet conversion
func stringToNetIPNetHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
if f.Kind() != reflect.String {
return data, nil
}
isPtr := t.Kind() == reflect.Ptr
targetType := t
if isPtr {
targetType = t.Elem()
}
if targetType != reflect.TypeOf(net.IPNet{}) {
return data, nil
}
str := data.(string)
if len(str) > MaxCIDRLength {
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR length: %d", len(str)))
}
_, ipnet, err := net.ParseCIDR(str)
if err != nil {
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR: %w", err))
}
if isPtr {
return ipnet, nil
}
return *ipnet, nil
}
}
// stringToURLHookFunc handles url.URL conversion
func stringToURLHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
if f.Kind() != reflect.String {
return data, nil
}
isPtr := t.Kind() == reflect.Ptr
targetType := t
if isPtr {
targetType = t.Elem()
}
if targetType != reflect.TypeOf(url.URL{}) {
return data, nil
}
str := data.(string)
if len(str) > MaxURLLength {
return nil, wrapError(ErrDecode, fmt.Errorf("URL too long: %d bytes", len(str)))
}
u, err := url.Parse(str)
if err != nil {
return nil, wrapError(ErrDecode, fmt.Errorf("invalid URL: %w", err))
}
if isPtr {
return u, nil
}
return *u, nil
}
}
// customDecodeHook allows for application-specific type conversions
func (c *Config) customDecodeHook() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
// TODO: Add support of custom validation for application types here
// Example: Rate limit parsing, permission validation, etc.
// Pass through by default
return data, nil
}
}
// navigateToPath traverses nested map to reach the specified path
func navigateToPath(nested map[string]any, path string) any {
if path == "" {
return nested
}
path = strings.TrimSuffix(path, ".")
if path == "" {
return nested
}
segments := strings.Split(path, ".")
current := any(nested)
for _, segment := range segments {
currentMap, ok := current.(map[string]any)
if !ok {
var current any = nested
for _, key := range strings.Split(strings.TrimSuffix(path, "."), ".") {
m, err := normalizeMap(current)
if err != nil {
return nil
}
value, exists := currentMap[segment]
if !exists {
return nil
}
current = value
current = m[key]
}
return current
}
}