diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..67b7f42 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: Test +on: [push, pull_request] +permissions: + contents: read +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go test -timeout 60s -race ./... + - run: GOARCH=386 go test -timeout 60s ./... + - run: go vet ./... + - run: go test -run '^$' -fuzz '^FuzzConfigNumericConversions$' -fuzztime=10s -parallel=2 + - run: GOOS=windows GOARCH=amd64 go test -c -o /tmp/config-windows.test.exe . diff --git a/README.md b/README.md index 869b91e..ca8d427 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,113 @@ -# Config +# config -Thread-safe configuration management for Go applications with support for multiple sources (files, environment variables, command-line arguments, defaults) and configurable precedence. - -## Features - -- **Multiple Sources**: Load configuration from defaults, files, environment variables, and CLI arguments -- **Configurable Precedence**: Control which sources override others -- **Type Safety**: Struct-based configuration with automatic validation -- **Thread-Safe**: Concurrent access with read-write locking -- **File Watching**: Automatic reloading on configuration changes -- **Source Tracking**: Know exactly where each value came from -- **Tag Support**: Use `toml`, `json`, or `yaml` struct tags - -## Installation - -```bash -go get github.com/lixenwraith/config -``` - -## Quick Start +Thread-safe configuration from TOML files, environment variables, command-line +arguments and registered defaults. Requires Go 1.27.1. The only runtime dependency +is [lixenwraith/toml](https://github.com/lixenwraith/toml), pinned to merged commit +`b59d068ff6a4`. Testify and its dependencies are used only by tests. ```go package main import ( "log" + "time" "github.com/lixenwraith/config" ) -type AppConfig struct { - Server struct { - Host string `toml:"host"` - Port int `toml:"port"` - } `toml:"server"` - Debug bool `toml:"debug"` +type Settings struct { + Port uint16 `toml:"port"` + Timeout time.Duration `toml:"timeout"` } func main() { - defaults := &AppConfig{} - defaults.Server.Host = "localhost" - defaults.Server.Port = 8080 - - cfg, err := config.Quick(defaults, "MYAPP_", "config.toml") - if err != nil { - log.Fatal(err) - } - - port, _ := cfg.Get("server.port") - log.Printf("Server port: %d", port.(int64)) + initial := Settings{Port: 8080, Timeout: 5 * time.Second} + cfg, err := config.NewBuilder(). + WithTarget(&initial). + WithFile("config.toml"). + WithEnvPrefix("APP_"). + WithArgs([]string{"--port=9090"}). + Build() + if err != nil { log.Fatal(err) } + latest, err := cfg.AsStruct() + if err != nil { log.Fatal(err) } + log.Printf("%+v", latest.(*Settings)) } ``` +```toml +# Service settings +timeout = "10s" +port = 8080 +``` + +Precedence is CLI > environment > file > default. Register paths with `Register` +or structs with `RegisterStruct`; defaults define the types used to validate new +values. `LoadFile`, `LoadEnv` and `LoadCLI` replace their complete source. A failed +source load keeps its previous values. Removing an environment variable, CLI flag +or file key removes the old override on the next load. + +`Get` returns a detached raw source value: environment and CLI values remain +strings, TOML integers are int64. `GetTyped[T]`, `Scan`, `ScanSource`, `ScanTyped[T]` +and `ScanMap` provide checked conversions. Errors leave destinations unchanged; +missing struct fields retain their existing values. `AsStruct` returns a detached +snapshot on every call. Build fills `WithTarget` once; subsequent reloads never +mutate that caller-owned object. + +## Migration + +- Only TOML files and `toml` tags are supported. JSON/YAML parsers, format constants + and alternate tag modes are removed. Existing format/tag option methods accept + only `toml` (`auto` remains an alias for TOML for file options). +- Numeric conversion rejects overflow, negative unsigned inputs, fractional + integer conversion, non-finite floats and integer-to-float precision loss. + Float64-to-float32 rounding within range remains supported. Full uint64 is + supported in memory; saving a value above MaxInt64 fails without changing the + destination file. Use an application-level string representation if needed. +- Strings can represent decimal numbers, booleans, comma-separated slices/arrays, + durations, RFC3339 timestamps, IP addresses, CIDR networks and URLs. Boolean to + string conversion uses `true`/`false`. There are no user decode hooks or automatic + `encoding.TextUnmarshaler` calls. +- Setters and source loads reject values incompatible with registered defaults + immediately. Check their errors. Untyped nil defaults leave the value type open; + typed nil pointers retain their declared type. Unsupported values, non-finite + floats and cyclic/deep graphs are rejected. Configuration value depth is 128. +- All public values, defaults, options and clones own their mutable containers. + Existing references returned from `AsStruct` are snapshots, not live objects. +- Saves standardize formatting and retain loaded comments as a preamble. Comment + positions are not preserved. Existing file permissions are retained; new files + use 0600. Only registered paths are saved, so unknown file keys are omitted. +- Paths use dot-separated ASCII key segments. Numeric-only segments and overlapping + registrations such as `server` plus `server.port` are rejected. Embedded fields + are not promoted. Recursive struct schemas are unsupported. + +## Watching + +```go +changes := cfg.WatchWithOptions(config.DefaultWatchOptions()) +defer cfg.StopAutoUpdate() +for event := range changes { + // Events are hints; always read the latest complete state. + log.Print(event) + latest, err := cfg.AsStruct() + if err != nil { log.Print(err); continue } + _ = latest.(*Settings) +} +``` + +The watcher fingerprints file contents, waits for a quiet debounce interval, then +validates and publishes. Invalid edits, timeouts, deletion and blocked permission +changes retain the last valid state. Cancelled, expired or superseded reloads +cannot publish. Stop closes subscriptions synchronously; a new Watch call can +restart watching. Notifications are buffered and best effort. + ## Documentation -- [Quick Start](doc/quick-start.md) - Get started -- [Builder Pattern](doc/builder.md) - Advanced configuration with the builder -- [Access Patterns](doc/access.md) - Getting and setting values -- [Command Line](doc/cli.md) - CLI argument handling -- [Environment Variables](doc/env.md) - Environment variable handling -- [File Configuration](doc/file.md) - File formats and loading -- [Validation](doc/validator.md) - Validation functions and integration -- [Live Reconfiguration](doc/reconfiguration.md) - File watching and auto-update on change -- [Quick Guide](doc/quick-guide_lixenwraith_config.md) - Quick reference guide +- [Quick start](doc/quick-start.md), [builder](doc/builder.md), [access](doc/access.md) +- [Files and saving](doc/file.md), [environment](doc/env.md), [CLI](doc/cli.md) +- [Live reconfiguration](doc/reconfiguration.md), [validation](doc/validator.md) +- [Architecture](doc/architecture.md), [audit evidence](doc/audit.md) -## License +Run `go test -race ./...` and `go vet ./...`. CI also executes the 386 suite, +cross-compiles for Windows and runs bounded numeric conversion fuzzing. -BSD-3-Clause \ No newline at end of file +BSD-3-Clause; see LICENSE. diff --git a/builder.go b/builder.go index 9d4b655..7e14441 100644 --- a/builder.go +++ b/builder.go @@ -66,6 +66,15 @@ func (b *Builder) Build() (*Config, error) { // If WithDefaults() was called, it takes precedence // If not, but WithTarget() was called, use the target struct for defaults if b.defaults != nil { + if b.cfg.structCache != nil { + t := reflect.TypeOf(b.defaults) + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if t != b.cfg.structCache.targetType { + return nil, wrapError(ErrTypeMismatch, fmt.Errorf("explicit defaults must have the target struct type")) + } + } // WithDefaults() was called explicitly. if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil { return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register defaults: %w", err)) @@ -80,6 +89,9 @@ func (b *Builder) Build() (*Config, error) { // Explicitly set the file path on the config object so the watcher can find it, // even if the initial load fails with a non-fatal error (file not found) b.cfg.configFilePath = b.file + if b.cfg.structCache != nil { + b.cfg.structCache.prefix = strings.TrimSuffix(b.prefix, ".") + } // 2. Load configuration loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts) @@ -89,6 +101,9 @@ func (b *Builder) Build() (*Config, error) { } // 3. Run non-typed validators + if err := b.cfg.Validate(); err != nil { + return nil, err + } for _, validator := range b.validators { if err := validator(b.cfg); err != nil { return nil, wrapError(ErrValidation, fmt.Errorf("configuration validation failed: %w", err)) @@ -96,13 +111,15 @@ func (b *Builder) Build() (*Config, error) { } // 4. Populate target and run typed validators - if b.cfg.structCache != nil && b.cfg.structCache.target != nil && len(b.typedValidators) > 0 { + if b.cfg.structCache != nil && b.cfg.structCache.target != nil { // Populate the target struct first, unifying all types (e.g., string "8888" -> int64 8888) populatedTarget, err := b.cfg.AsStruct() if err != nil { return nil, wrapError(ErrValidation, fmt.Errorf("failed to populate target struct for validation: %w", err)) } + reflect.ValueOf(b.cfg.structCache.target).Elem().Set(reflect.ValueOf(populatedTarget).Elem()) + // Run the typed validators against the populated, type-safe struct for _, validator := range b.typedValidators { validatorFunc := reflect.ValueOf(validator) @@ -145,16 +162,16 @@ func (b *Builder) WithDefaults(defaults any) *Builder { } // WithTagName sets the struct tag name to use for field mapping -// Supported values: toml (default), json, yaml +// Supported values: toml only func (b *Builder) WithTagName(tagName string) *Builder { switch tagName { - case FormatTOML, FormatJSON, FormatYAML: + case FormatTOML: b.tagName = tagName if b.cfg != nil { // Ensure cfg exists b.cfg.tagName = tagName } default: - b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName)) + b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml", tagName)) } return b } @@ -162,7 +179,7 @@ func (b *Builder) WithTagName(tagName string) *Builder { // WithFileFormat sets the expected file format func (b *Builder) WithFileFormat(format string) *Builder { switch format { - case FormatTOML, FormatJSON, FormatYAML, FormatAuto: + case FormatTOML, FormatAuto: b.fileFormat = format default: b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported file format %q", format)) @@ -196,13 +213,19 @@ func (b *Builder) WithFile(path string) *Builder { // WithArgs sets the command-line arguments func (b *Builder) WithArgs(args []string) *Builder { - b.args = args + b.args = append([]string(nil), args...) return b } // WithSources sets the precedent order for configuration sources func (b *Builder) WithSources(sources ...Source) *Builder { - b.opts.Sources = sources + for _, source := range sources { + if !validSource(source) { + b.err = wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source)) + return b + } + } + b.opts.Sources = append([]Source(nil), sources...) return b } @@ -331,6 +354,9 @@ func (b *Builder) WithTypedValidator(fn any) *Builder { return b } + if reflect.ValueOf(fn).IsNil() { + return b + } b.typedValidators = append(b.typedValidators, fn) return b -} \ No newline at end of file +} diff --git a/builder_test.go b/builder_test.go index a79d29d..96288ad 100644 --- a/builder_test.go +++ b/builder_test.go @@ -43,8 +43,8 @@ func TestBuilder(t *testing.T) { os.WriteFile(configFile, []byte(`host = "filehost"`), 0644) type Config struct { - Host string `json:"hostname"` - Port int `json:"port"` + Host string `toml:"hostname"` + Port int `toml:"port"` } defaults := &Config{ @@ -59,7 +59,7 @@ func TestBuilder(t *testing.T) { cfg, err := NewBuilder(). WithDefaults(defaults). - WithTagName("json"). + WithTagName("toml"). WithPrefix("server"). WithEnvPrefix("APP_"). WithFile(configFile). @@ -360,4 +360,4 @@ func TestBuilderWithTypedValidator(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "typed validator signature") }) -} \ No newline at end of file +} diff --git a/config.go b/config.go index 73b5e6c..fcdb888 100644 --- a/config.go +++ b/config.go @@ -13,6 +13,7 @@ import ( // configItem holds configuration values from different sources type configItem struct { + required bool defaultValue any values map[Source]any // Values from each source currentValue any // Computed value based on precedence @@ -22,8 +23,10 @@ type configItem struct { type structCache struct { target any // User-provided struct pointer targetType reflect.Type // Cached type for validation - version int64 // Version for invalidation - populated bool // Whether cache is valid + snapshot any // Immutable cached result; callers receive copies + prefix string + version int64 // Version for invalidation + populated bool // Whether cache is valid mu sync.RWMutex } @@ -35,20 +38,22 @@ type SecurityOptions struct { } // Config manages application configuration. It can be used in two primary ways: -// 1. As a dynamic key-value store, accessed via methods like Get(), String(), and Int64() -// 2. As a source for a type-safe struct, populated via BuildAndScan() or AsStruct() +// 1. As a dynamic key-value store, accessed via Get() and GetTyped[T]() +// 2. As a source for typed structs, populated via Scan() or AsStruct() type Config struct { - items map[string]configItem - tagName string - fileFormat string // Separate from tagName: toml, json, yaml, or auto - securityOpts *SecurityOptions - mutex sync.RWMutex - options LoadOptions // Current load options - fileData map[string]any // Cached file data - envData map[string]any // Cached env data - cliData map[string]any // Cached CLI data - version atomic.Int64 - structCache *structCache + items map[string]configItem + tagName string + fileFormat string // Separate from tagName: toml or auto + securityOpts *SecurityOptions + mutex sync.RWMutex + options LoadOptions // Current load options + fileData map[string]any // Cached file data + fileComments []string + fileGeneration uint64 // Invalidates staged watcher reloads + envData map[string]any // Cached env data + cliData map[string]any // Cached CLI data + version atomic.Int64 + structCache *structCache // CLI paths that matched no registered path (reset on each CLI load) unknownCLIKeys []string @@ -74,7 +79,7 @@ func New() *Config { // NewWithOptions creates a new Config instance with custom load options func NewWithOptions(opts LoadOptions) *Config { c := New() - c.options = opts + c.options = cloneLoadOptions(opts) return c } @@ -83,17 +88,19 @@ func (c *Config) SetLoadOptions(opts LoadOptions) { c.mutex.Lock() defer c.mutex.Unlock() - c.options = opts + c.options = cloneLoadOptions(opts) // Recompute all current values based on new precedence for path, item := range c.items { item.currentValue = c.computeValue(item) c.items[path] = item } + c.invalidateCache() } // SetPrecedence updates source precedence with validation func (c *Config) SetPrecedence(sources ...Source) error { + sources = slices.Clone(sources) // Validate all required sources present required := map[Source]bool{ SourceDefault: false, @@ -167,10 +174,10 @@ func (c *Config) GetPrecedence() []Source { // Use "auto" to detect based on file extension func (c *Config) SetFileFormat(format string) error { switch format { - case FormatTOML, FormatJSON, FormatYAML, FormatAuto: + case FormatTOML, FormatAuto: // Valid formats default: - return wrapError(ErrFileFormat, fmt.Errorf("unsupported file format %q, must be one of: toml, json, yaml, auto", format)) + return wrapError(ErrFileFormat, fmt.Errorf("unsupported file format %q, must be one of: toml, auto", format)) } c.mutex.Lock() @@ -185,6 +192,7 @@ func (c *Config) SetSecurityOptions(opts SecurityOptions) { c.mutex.Lock() defer c.mutex.Unlock() c.securityOpts = &opts + c.fileGeneration++ } // Get retrieves a configuration value using the path and indicator if the path was registered @@ -197,7 +205,7 @@ func (c *Config) Get(path string) (any, bool) { return nil, false } - return item.currentValue, true + return cloneOwned(item.currentValue), true } // GetSource retrieves a value from a specific source @@ -211,7 +219,10 @@ func (c *Config) GetSource(path string, source Source) (any, bool) { } val, exists := item.values[source] - return val, exists + if source == SourceDefault { + return cloneOwned(item.defaultValue), true + } + return cloneOwned(val), exists } // Set updates a configuration value for the given path @@ -219,14 +230,19 @@ func (c *Config) GetSource(path string, source Source) (any, bool) { // By default, this is SourceCLI. Returns an error if the path is not registered // To set a value in a specific source, use SetSource instead func (c *Config) Set(path string, value any) error { - return c.SetSource(c.options.Sources[0], path, value) + c.mutex.Lock() + defer c.mutex.Unlock() + return c.setSourceLocked(c.options.Sources[0], path, value) } // SetSource sets a value for a specific source func (c *Config) SetSource(source Source, path string, value any) error { c.mutex.Lock() defer c.mutex.Unlock() + return c.setSourceLocked(source, path, value) +} +func (c *Config) setSourceLocked(source Source, path string, value any) error { item, registered := c.items[path] if !registered { return wrapError(ErrPathNotRegistered, fmt.Errorf("path %s is not registered", path)) @@ -240,7 +256,29 @@ func (c *Config) SetSource(source Source, path string, value any) error { item.values = make(map[Source]any) } - item.values[source] = value + if !validSource(source) { + return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source)) + } + if err := validateValue(item, value); err != nil { + return err + } + owned, err := copyValue(value) + if err != nil { + return err + } + value = owned + if source == SourceDefault { + if item.defaultValue != nil { + target := reflect.New(reflect.TypeOf(item.defaultValue)) + if err := decodeConfig(value, target.Interface()); err != nil { + return err + } + value = target.Elem().Interface() + } + item.defaultValue = value + } else { + item.values[source] = value + } item.currentValue = c.computeValue(item) c.items[path] = item @@ -271,7 +309,7 @@ func (c *Config) GetSources(path string) map[Source]any { result := make(map[Source]any) for source, value := range item.values { - result[source] = value + result[source] = cloneOwned(value) } return result } @@ -285,6 +323,8 @@ func (c *Config) Reset() { c.fileData = make(map[string]any) c.envData = make(map[string]any) c.cliData = make(map[string]any) + c.unknownCLIKeys = nil + c.fileGeneration++ // Reset all items to default values for path, item := range c.items { @@ -305,10 +345,12 @@ func (c *Config) ResetSource(source Source) { switch source { case SourceFile: c.fileData = make(map[string]any) + c.fileGeneration++ case SourceEnv: c.envData = make(map[string]any) case SourceCLI: c.cliData = make(map[string]any) + c.unknownCLIKeys = nil } // Remove source values from all items @@ -323,22 +365,24 @@ func (c *Config) ResetSource(source Source) { // AsStruct returns the populated struct if in type-aware mode func (c *Config) AsStruct() (any, error) { - if c.structCache == nil || c.structCache.target == nil { + c.mutex.RLock() + defer c.mutex.RUnlock() + if c.structCache == nil { return nil, wrapError(ErrNotConfigured, fmt.Errorf("no target struct configured")) } - - c.structCache.mu.RLock() - currentVersion := c.version.Load() - needsUpdate := !c.structCache.populated || c.structCache.version != currentVersion - c.structCache.mu.RUnlock() - - if needsUpdate { - if err := c.populateStruct(); err != nil { + cache := c.structCache + cache.mu.Lock() + defer cache.mu.Unlock() + version := c.version.Load() + if !cache.populated || cache.version != version { + next := reflect.New(cache.targetType) + if err := decodeConfig(navigateToPath(c.nestedLocked(""), cache.prefix), next.Interface()); err != nil { return nil, err } + cache.snapshot = next.Interface() + cache.version, cache.populated = version, true } - - return c.structCache.target, nil + return copyValue(cache.snapshot) } // UnknownCLIKeys returns CLI paths that matched no registered config path @@ -352,6 +396,9 @@ func (c *Config) UnknownCLIKeys() []string { func (c *Config) computeValue(item configItem) any { // Check sources in precedence order for _, source := range c.options.Sources { + if source == SourceDefault { + return item.defaultValue + } if val, exists := item.values[source]; exists && val != nil { return val } @@ -361,25 +408,6 @@ func (c *Config) computeValue(item configItem) any { return item.defaultValue } -// populateStruct updates the cached struct representation using unified unmarshal -func (c *Config) populateStruct() error { - c.structCache.mu.Lock() - defer c.structCache.mu.Unlock() - - currentVersion := c.version.Load() - if c.structCache.populated && c.structCache.version == currentVersion { - return nil - } - - if err := c.unmarshal("", c.structCache.target); err != nil { - return wrapError(ErrDecode, fmt.Errorf("failed to populate struct cache: %w", err)) - } - - c.structCache.version = currentVersion - c.structCache.populated = true - return nil -} - // invalidateCache override Set methods to invalidate cache func (c *Config) invalidateCache() { c.version.Add(1) diff --git a/config_test.go b/config_test.go index 2c1f567..d3a3623 100644 --- a/config_test.go +++ b/config_test.go @@ -124,15 +124,10 @@ func TestComplexStructRegistration(t *testing.T) { assert.Equal(t, 30*time.Second, val) }) - t.Run("JSONTags", func(t *testing.T) { + t.Run("RejectJSONTags", func(t *testing.T) { cfg := New() - err := cfg.RegisterStructWithTags("", defaultConfig, "json") - require.NoError(t, err) - - // JSON tags should create different paths - paths := cfg.GetRegisteredPaths("") - assert.True(t, paths["db.db_host"]) - assert.True(t, paths["db.db_port"]) + require.Error(t, cfg.RegisterStructWithTags("", defaultConfig, "json")) + require.Empty(t, cfg.GetRegisteredPaths()) }) t.Run("UnsupportedTag", func(t *testing.T) { @@ -446,7 +441,7 @@ func TestPrecedenceWithAutoUpdate(t *testing.T) { assert.Equal(t, "cli-server", val) } -// TestTypeConversion tests automatic type conversion through mapstructure +// TestTypeConversion tests automatic type conversion through checked decoding func TestTypeConversion(t *testing.T) { type TestConfig struct { IntValue int64 `toml:"int"` @@ -486,7 +481,7 @@ func TestTypeConversion(t *testing.T) { cfg.SetSource(SourceEnv, "ipnet", "10.0.0.0/8") cfg.SetSource(SourceEnv, "url", "https://example.com:8080/path") cfg.SetSource(SourceEnv, "strings", "x,y,z") - // cfg.SetSource("ints", SourceEnv, "7,8,9") // failure due to mapstructure limitation + require.NoError(t, cfg.SetSource(SourceEnv, "ints", "7,8,9")) // Scan into struct var result TestConfig @@ -538,7 +533,7 @@ func TestConcurrentAccess(t *testing.T) { defer wg.Done() for j := 0; j < 100; j++ { path := fmt.Sprintf("path%d", j) - value := fmt.Sprintf("writer%d-value%d", id, j) + value := id*100 + j if err := cfg.Set(path, value); err != nil { errors <- fmt.Errorf("writer %d: %v", id, err) } @@ -555,7 +550,7 @@ func TestConcurrentAccess(t *testing.T) { for j := 0; j < 50; j++ { path := fmt.Sprintf("path%d", j) source := sources[j%len(sources)] - value := fmt.Sprintf("source%d-value%d", id, j) + value := id*100 + j if err := cfg.SetSource(source, path, value); err != nil { errors <- fmt.Errorf("source writer %d: %v", id, err) } diff --git a/constant.go b/constant.go index ce9ef8b..1e97842 100644 --- a/constant.go +++ b/constant.go @@ -31,8 +31,6 @@ const ( // Format identifiers const ( FormatTOML = "toml" - FormatJSON = "json" - FormatYAML = "yaml" FormatAuto = "auto" ) @@ -62,4 +60,4 @@ const ( var ( DefaultConfigExtensions = []string{".toml", ".conf", ".config"} XDGSystemPaths = []string{"/etc/xdg", "/etc"} -) \ No newline at end of file +) diff --git a/decode.go b/decode.go index 09cb8b6..2db10c7 100644 --- a/decode.go +++ b/decode.go @@ -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 -} \ No newline at end of file +} diff --git a/decode_test.go b/decode_test.go index 4f1dfc7..f826a3d 100644 --- a/decode_test.go +++ b/decode_test.go @@ -4,6 +4,7 @@ package config import ( "net" "net/url" + "strings" "testing" "time" @@ -135,7 +136,7 @@ func TestScanFromSource(t *testing.T) { {SourceFile, "fromfile"}, {SourceEnv, "fromenv"}, {SourceCLI, "fromcli"}, - {SourceDefault, ""}, // No value in default source + {SourceDefault, "default"}, // Registered defaults are a readable source } for _, tt := range tests { @@ -174,90 +175,30 @@ func TestInvalidScanTargets(t *testing.T) { // TestCustomTypeConversion tests edge cases in type conversion func TestCustomTypeConversion(t *testing.T) { - cfg := New() - - t.Run("InvalidIPAddress", func(t *testing.T) { - type Config struct { - IP net.IP `toml:"ip"` - } - - cfg.Register("ip", net.IP{}) - cfg.Set("ip", "not-an-ip") - - var result Config - err := cfg.Scan(&result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid IP address") - }) - - t.Run("InvalidCIDR", func(t *testing.T) { - type Config struct { - Network *net.IPNet `toml:"network"` - } - - cfg.Register("network", (*net.IPNet)(nil)) - cfg.Set("network", "invalid-cidr") - - var result Config - err := cfg.Scan(&result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid CIDR") - }) - - t.Run("InvalidURL", func(t *testing.T) { - type Config struct { - Endpoint *url.URL `toml:"endpoint"` - } - - cfg.Register("endpoint", (*url.URL)(nil)) - cfg.Set("endpoint", "://invalid-url") - - var result Config - err := cfg.Scan(&result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid URL") - }) - - t.Run("LongIPString", func(t *testing.T) { - type Config struct { - IP net.IP `toml:"ip"` - } - - cfg.Register("ip", net.IP{}) - // String longer than max IPv6 length - longIP := make([]byte, 50) - for i := range longIP { - longIP[i] = 'x' - } - cfg.Set("ip", string(longIP)) - - var result Config - err := cfg.Scan(&result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "invalid IP length") - }) - - t.Run("LongURL", func(t *testing.T) { - type Config struct { - URL *url.URL `toml:"url"` - } - - cfg.Register("url", (*url.URL)(nil)) - // URL longer than 2048 bytes - longURL := "https://example.com/" - for i := 0; i < 2048; i++ { - longURL += "x" - } - cfg.Set("url", longURL) - - var result Config - err := cfg.Scan(&result) - assert.Error(t, err) - assert.Contains(t, err.Error(), "URL too long") - }) + for _, tc := range []struct { + name string + initial any + input string + fragment string + }{ + {"IP", net.IP{}, "not-an-ip", "invalid IP address"}, + {"CIDR", (*net.IPNet)(nil), "invalid-cidr", "invalid CIDR"}, + {"URL", (*url.URL)(nil), "://invalid-url", "invalid URL"}, + {"LongIP", net.IP{}, strings.Repeat("x", 50), "invalid IP length"}, + {"LongURL", (*url.URL)(nil), strings.Repeat("x", MaxURLLength+1), "URL too long"}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := New() + require.NoError(t, cfg.Register("value", tc.initial)) + err := cfg.Set("value", tc.input) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.fragment) + got, _ := cfg.Get("value") + assert.Equal(t, tc.initial, got) + }) + } } -// TestZeroFields tests that ZeroFields option works correctly func TestZeroFields(t *testing.T) { type Config struct { KeepValue string `toml:"keep"` @@ -324,5 +265,5 @@ func TestWeaklyTypedInput(t *testing.T) { assert.Equal(t, 3.14159, result.FloatFromString) assert.Equal(t, true, result.BoolFromString) assert.Equal(t, "12345", result.StringFromInt) - assert.Equal(t, "1", result.StringFromBool) // mapstructure converts bool(true) to "1" in weak conversion + assert.Equal(t, "true", result.StringFromBool) // Canonical boolean text } diff --git a/discovery.go b/discovery.go index de5cc2f..c053411 100644 --- a/discovery.go +++ b/discovery.go @@ -67,4 +67,4 @@ func getXDGConfigPaths(appName string) []string { } return paths -} \ No newline at end of file +} diff --git a/doc/access.md b/doc/access.md index f8af94e..8e3ffef 100644 --- a/doc/access.md +++ b/doc/access.md @@ -1,401 +1,37 @@ -# Access Patterns - -This guide covers all methods for getting and setting configuration values, type conversions, and working with structured data. - -**Always Register First**: Register paths before setting values -**Use Type Assertions**: After struct registration, types are guaranteed - -## Getting Values - -### Basic Get - -```go -// Get returns (value, exists) -value, exists := cfg.Get("server.port") -if !exists { - log.Fatal("server.port not configured") -} - -// Type assertion (safe after registration) -port := value.(int64) -``` - -### Type-Safe Access - -When using struct registration, types are guaranteed: - -```go -type Config struct { - Server struct { - Port int64 `toml:"port"` - Host string `toml:"host"` - } `toml:"server"` -} - -cfg.RegisterStruct("", &Config{}) - -// After registration, type assertions are safe -port, _ := cfg.Get("server.port") -portNum := port.(int64) // Won't panic - type is enforced -``` - -### Get from Specific Source - -```go -// Get value from specific source -envPort, exists := cfg.GetSource(config.SourceEnv, "server.port") -if exists { - log.Printf("Port from environment: %v", envPort) -} - -// Check all sources -sources := cfg.GetSources("server.port") -for source, value := range sources { - log.Printf("%s: %v", source, value) -} -``` - -### Struct Scanning - -```go -// Scan into struct -var serverConfig struct { - Host string `toml:"host"` - Port int64 `toml:"port"` - TLS struct { - Enabled bool `toml:"enabled"` - Cert string `toml:"cert"` - } `toml:"tls"` -} - -if err := cfg.Scan(&serverConfig, "server"); err != nil { - log.Fatal(err) -} - -// Use structured data -log.Printf("Server: %s:%d", serverConfig.Host, serverConfig.Port) -``` - -### Target Population - -```go -// Populate entire config struct -var config AppConfig -if err := cfg.Target(&config); err != nil { - log.Fatal(err) -} - -// Or with builder pattern -var config AppConfig -cfg, _ := config.NewBuilder(). - WithTarget(&config). - Build() - -// Access directly -fmt.Println(config.Server.Port) -``` - -### GetTyped - -Retrieves a single configuration value and decodes it to the specified type. - -```go -import "time" - -// Returns an int, converting from string "9090" if necessary. -port, err := config.GetTyped[int](cfg, "server.port") - -// Returns a time.Duration, converting from string "5m30s". -timeout, err := config.GetTyped[time.Duration](cfg, "server.timeout") -``` - -### ScanTyped - -A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of the specified type. - -```go -// Instead of: -// var dbConf DBConfig -// if err := cfg.Scan("database", &dbConf); err != nil { ... } - -// You can write: -dbConf, err := config.ScanTyped[DBConfig](cfg, "database") -if err != nil { - // ... -} -// dbConf is a *DBConfig``` -``` - -### Type-Aware Mode - -```go -var conf AppConfig - -cfg, _ := config.NewBuilder(). - WithTarget(&conf). - Build() - -// Get updated struct anytime -latest, err := cfg.AsStruct() -if err != nil { - log.Fatal(err) -} -appConfig := latest.(*AppConfig) -``` - -## Setting Values - -### Basic Set - -```go -// Set updates the highest priority source (default: CLI) -if err := cfg.Set("server.port", int64(9090)); err != nil { - log.Fatal(err) // Error if path not registered -} -``` - -### Set in Specific Source - -```go -// Set value in specific source -cfg.SetSource(config.SourceEnv, "server.port", "8080") -cfg.SetSource(config.SourceCLI, "debug", true) - -// File source typically set via LoadFile, but can be manual -cfg.SetSource(config.SourceFile, "feature.enabled", true) -``` - -### Batch Updates - -```go -// Multiple updates -updates := map[string]any{ - "server.port": int64(9090), - "server.host": "0.0.0.0", - "database.maxconns": int64(50), -} - -for path, value := range updates { - if err := cfg.Set(path, value); err != nil { - log.Printf("Failed to set %s: %v", path, err) - } -} -``` - -## Type Conversions - -The package uses mapstructure for flexible type conversion: - -```go -// These all work for a string field -cfg.Set("name", "value") // Direct string -cfg.Set("name", 123) // Number → "123" -cfg.Set("name", true) // Boolean → "true" - -// For int64 fields -cfg.Set("port", int64(8080)) // Direct -cfg.Set("port", "8080") // String → int64 -cfg.Set("port", 8080.0) // Float → int64 -cfg.Set("port", int(8080)) // int → int64 -``` - -### Duration Handling - -```go -type Config struct { - Timeout time.Duration `toml:"timeout"` -} - -// All these work -cfg.Set("timeout", 30*time.Second) // Direct duration -cfg.Set("timeout", "30s") // String parsing -cfg.Set("timeout", "5m30s") // Complex duration -``` - -### Network Types - -```go -type Config struct { - IP net.IP `toml:"ip"` - CIDR net.IPNet `toml:"cidr"` - URL url.URL `toml:"url"` -} - -// Automatic parsing -cfg.Set("ip", "192.168.1.1") -cfg.Set("cidr", "10.0.0.0/8") -cfg.Set("url", "https://example.com:8080/path") -``` - -### Slice Handling - -```go -type Config struct { - Tags []string `toml:"tags"` - Ports []int `toml:"ports"` -} - -// Direct slice -cfg.Set("tags", []string{"prod", "stable"}) - -// Comma-separated string (from env/CLI) -cfg.Set("tags", "prod,stable,v2") - -// Number arrays -cfg.Set("ports", []int{8080, 8081, 8082}) -``` - -## Checking Configuration - -### Path Registration - -```go -// Check if path is registered -if _, exists := cfg.Get("server.port"); !exists { - log.Fatal("server.port not registered") -} - -// Get all registered paths -paths := cfg.GetRegisteredPaths("server.") -for path := range paths { - log.Printf("Registered: %s", path) -} - -// With default values -defaults := cfg.GetRegisteredPathsWithDefaults("") -for path, defaultVal := range defaults { - log.Printf("%s = %v (default)", path, defaultVal) -} -``` - -### Validation - -```go -// Check required fields -if err := cfg.Validate("api.key", "database.url"); err != nil { - log.Fatal("Missing required config:", err) -} - -// Custom validation -requiredPorts := []string{"server.port", "metrics.port"} -for _, path := range requiredPorts { - if val, exists := cfg.Get(path); exists { - if port := val.(int64); port < 1024 { - log.Fatalf("%s must be >= 1024", path) - } - } -} -``` - -### Source Inspection - -```go -// Debug specific value -path := "server.port" -log.Printf("=== %s ===", path) -log.Printf("Current: %v", cfg.Get(path)) - -sources := cfg.GetSources(path) -for source, value := range sources { - log.Printf(" %s: %v", source, value) -} -``` - -## Advanced Patterns - -### Dynamic Configuration - -```go -// Change configuration at runtime -func updatePort(cfg *config.Config, port int64) error { - if port < 1 || port > 65535 { - return fmt.Errorf("invalid port: %d", port) - } - return cfg.Set("server.port", port) -} -``` - -### Configuration Facade - -```go -type ConfigFacade struct { - cfg *config.Config -} - -func (f *ConfigFacade) ServerPort() int64 { - val, _ := f.cfg.Get("server.port") - return val.(int64) -} - -func (f *ConfigFacade) SetServerPort(port int64) error { - return f.cfg.Set("server.port", port) -} - -func (f *ConfigFacade) DatabaseURL() string { - val, _ := f.cfg.Get("database.url") - return val.(string) -} -``` - -### Default Fallbacks - -```go -// Helper for optional configuration -func getOrDefault(cfg *config.Config, path string, defaultVal any) any { - if val, exists := cfg.Get(path); exists { - return val - } - return defaultVal -} - -// Usage -timeout := getOrDefault(cfg, "timeout", 30*time.Second).(time.Duration) -``` - -## Thread Safety - -All access methods are thread-safe: - -```go -// Safe concurrent access -var wg sync.WaitGroup - -// Multiple readers -for i := 0; i < 10; i++ { - wg.Add(1) - go func() { - defer wg.Done() - port, _ := cfg.Get("server.port") - log.Printf("Port: %v", port) - }() -} - -// Concurrent writes are safe too -wg.Add(1) -go func() { - defer wg.Done() - cfg.Set("counter", atomic.AddInt64(&counter, 1)) -}() - -wg.Wait() -``` - -## Debugging - -### View All Configuration - -```go -// Debug output -fmt.Println(cfg.Debug()) - -// Dump as TOML -cfg.Dump() // Writes to stdout -``` - -### Clone for Testing - -```go -// Create isolated copy for testing -testCfg := cfg.Clone() -testCfg.Set("server.port", int64(0)) // Random port for tests -``` \ No newline at end of file +# Access and conversion + +| API | Behavior | +| --- | --- | +| `Get(path)` | Detached raw effective value and registration flag | +| `GetSource(path, source)` | Detached source value; Default returns the registered default | +| `GetSources(path)` | Detached map of explicit source overrides | +| `GetTyped[T](cfg, path)` | Checked conversion to T | +| `GetTypedWithDefault[T]` | Registers a default only when the path is absent; existing conversion errors are returned | +| `Scan(&target, optionalPath)` | Transactional decoding of current values | +| `ScanSource(source, &target, optionalPath)` | Decodes only the selected source | +| `ScanTyped[T]` | Allocates and populates a typed destination | +| `ScanMap(values, &target)` | Uses the same decoder without registration | +| `AsStruct()` | Independent snapshot of the configured target type | + +Raw environment/CLI values remain strings. TOML integer literals are int64. +Defaults retain their declared types. Use typed access instead of assuming a raw +source type. Matching uses `toml` tags, then case-sensitive Go field names. Tags +with an empty name use the field name; `-` skips a field. Unknown input fields and +unexported destination fields are ignored. Maps and slices replace existing +containers; fixed arrays require exact lengths. Missing struct fields remain as +provided by the caller. A present nil value clears its destination. + +Conversions support signed/unsigned numbers, floats, booleans, strings, slices, +arrays, string-keyed maps, structs and pointers. Durations, time.Time, net.IP, +net.IPNet and url.URL are atomic configuration values, including pointer forms. +They accept their native types or documented text representations. Comma-separated +strings can populate typed numeric slices as well as string slices. + +No truncating integer conversions or integer-to-float precision loss is allowed. +Float narrowing may round within range. NaN/Inf and unsupported object graphs are +rejected. All decoding failures leave the destination unchanged. There are no +custom decode hooks or automatic text-unmarshal interfaces. + +A Config owns accepted values. Modifying Get results, defaults after registration, +clones or AsStruct snapshots cannot modify stored state. Do not mutate arguments +concurrently while a call is reading them. Builder setup itself is not concurrent. diff --git a/doc/architecture.md b/doc/architecture.md index 1472be0..db5d3b5 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -1,174 +1,29 @@ -# Config Package Architecture +# Architecture -## Overview +Registered items hold a default, source overrides and an effective value selected +by precedence. A Config RWMutex protects these maps and runtime options. Values +are copied on admission and never mutated internally afterward; replacement removes +or swaps references. Public reads return copies. Nested snapshots can therefore be +assembled under a read lock and decoded after releasing it. -The `lixenwraith/config` package provides thread-safe configuration management with support for multiple sources, type-safe struct population, and live reconfiguration. +The decoder handles the limited configuration object model: TOML tags, standard +containers and atomic duration/time/network values. It delegates numeric conversion +to lixenwraith/toml and uses explicit decimal parsing for environment/CLI strings. +No mapstructure or external file parser is used in the runtime dependency graph. -## Logical Architecture +Typed cache metadata uses a second mutex, always acquired after the Config read +lock. Version changes invalidate its immutable decoded snapshot. Every AsStruct call +returns a detached copy, so publication cannot race with existing readers. Build +writes the supplied target once. Clone copies configuration state and options but +does not copy active watchers or subscriptions. -``` -┌──────────────────────────────────────────────────────────┐ -│ User API Layer │ -├────────────────┬────────────────┬────────────────────────┤ -│ Builder │ Type-Safe │ Dynamic Access │ -│ Pattern │ Struct API │ Key-Value API │ -├────────────────┴────────────────┴────────────────────────┤ -│ Core Config Engine │ -│ • Path Registration • Source Merging • Thread Safety │ -├──────────────────────────────────────────────────────────┤ -│ Source Loaders │ -│ File │ Environment │ CLI Arguments │ Defaults │ -├──────────────────────────────────────────────────────────┤ -│ Supporting Systems │ -│ Validation │ Type Decode │ File Watch │ Error Handling │ -└──────────────────────────────────────────────────────────┘ -``` +File reads and parsing precede source publication. All candidate values are checked +against the current schema, then the complete source is replaced under one lock. +The watcher additionally verifies cancellation, timeout and generation at commit. +One worker owns fingerprint/debounce state. Subscriber send/close operations share a +mutex; stop does not wait for that worker while holding the Config mutex. -## Component Interactions - -``` -Builder Flow: - NewBuilder() - ↓ - Configure (WithTarget, WithFile, WithEnvPrefix) - ↓ - Build() → Register Paths → Load Sources → Merge Values - ↓ - Config Instance - ↓ - AsStruct() / Get() / Watch() - -Value Resolution: - Path Request - ↓ - Check Registration - ↓ - Search Sources (CLI → Env → File → Default) - ↓ - Type Conversion - ↓ - Return Value - -Live Reload: - File Change Detected - ↓ - Debounce Timer - ↓ - Reload File - ↓ - Merge with Sources - ↓ - Notify Watchers -``` - -## File Organization - -### Core (`config.go`) -- **Config struct**: Thread-safe state management with atomic versioning -- **Initialization**: `New()`, `NewWithOptions()` -- **State Management**: `Get()`, `Set()`, `GetSource()`, `SetSource()` -- **Precedence Control**: `SetPrecedence()`, `GetPrecedence()`, `computeValue()` -- **Cache Management**: `AsStruct()`, `populateStruct()`, `invalidateCache()` -- **Source Operations**: `Reset()`, `ResetSource()`, `GetSources()` - -### Registration (`register.go`) -- **Path Registration**: `Register()`, `RegisterWithEnv()`, `RegisterRequired()`, `Unregister()` -- **Struct Registration**: `RegisterStruct()`, `RegisterStructWithTags()`, `registerFields()` -- **Discovery**: `GetRegisteredPaths()`, `GetRegisteredPathsWithDefaults()` -- **Decoding Bridge**: `Scan()`, `ScanSource()` - delegates to decode.go - -### Builder Pattern (`builder.go`) -- **Fluent API**: `NewBuilder()`, `Build()`, `MustBuild()` -- **Configuration**: `WithTarget()`, `WithDefaults()`, `WithFile()`, `WithEnvPrefix()` -- **Discovery Integration**: `WithFileDiscovery()` -- **Validation**: `WithValidator()`, `WithTypedValidator()` -- **Security**: `WithSecurityOptions()` - -### Source Loading (`loader.go`) -- **Multi-Source Loading**: `loadWithOptions()` -- **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()` -- **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()` -- **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()` -- **Parsing**: `parseArgs()`, `parseValue()`, `detectFileFormat()` -- **Security Checks**: Path traversal, file ownership, size limits - -### Type System (`decode.go`) -- **Unified Decoding**: `unmarshal()` - single authoritative decoder -- **Hook Composition**: `getDecodeHook()`, `customDecodeHook()` -- **Type Converters**: `jsonNumberHookFunc()`, `stringToNetIPHookFunc()`, `stringToURLHookFunc()` -- **Navigation**: `navigateToPath()`, `normalizeMap()` - -### File Watching (`watch.go`) -- **Auto-Reload**: `AutoUpdate()`, `AutoUpdateWithOptions()`, `StopAutoUpdate()` -- **Subscriptions**: `Watch()`, `WatchWithOptions()`, `WatchFile()` -- **Watcher State**: `watcher` struct with debouncing, polling, version tracking -- **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()` -- **Resource Management**: Max watcher limits, graceful shutdown - -### Convenience API (`utility.go`) -- **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()` -- **Flag Integration**: `GenerateFlags()`, `BindFlags()` -- **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()` -- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`, `ScanMap()` - -### File Discovery (`discovery.go`) -- **Options**: `FileDiscoveryOptions` struct with search strategies -- **XDG Compliance**: `getXDGConfigPaths()` for standard config locations -- **Defaults**: `DefaultDiscoveryOptions()` with sensible patterns - -### Validation Library (`validator.go`) -- **Network**: `Port()`, `IPAddress()`, `IPv4Address()`, `IPv6Address()` -- **Numeric**: `Positive()`, `NonNegative()`, `Range()` -- **String**: `NonEmpty()`, `Pattern()`, `OneOf()`, `URLPath()` - -### Error System (`error.go`) -- **Categories**: Sentinel errors for `errors.Is()` checking -- **Wrapping**: `wrapError()` maintains dual error chains - -### Internal Utilities (`helper.go`) -- **Map Operations**: `flattenMap()`, `setNestedValue()` -- **Validation**: `isValidKeySegment()` for path validation - -### Constants (`constant.go`) -- **Shared Constants**: Defines shared constants for timing, formats, limits, file watcher and discovery. - -## Data Flow Patterns - -### Configuration Loading -1. **Registration Phase**: Paths registered with types/defaults -2. **Source Collection**: Each source populates its layer -3. **Merge Phase**: Precedence determines final values -4. **Population Phase**: Struct populated via reflection - -### Thread Safety Model -- **Read Operations**: Multiple concurrent readers via RLock -- **Write Operations**: Exclusive access via Lock -- **Atomic Updates**: Prepare → Lock → Swap → Unlock pattern -- **Version Tracking**: Atomic counter for cache invalidation - -### Error Propagation -- **Wrapped Errors**: Category + specific error detail -- **Early Return**: Builder accumulates first error -- **Panic Mode**: MustBuild for fail-fast scenarios - -## Extension Points - -### Custom Types -Implement decode hooks in `customDecodeHook()`: -```go -func(f reflect.Type, t reflect.Type, data any) (any, error) -``` - -### Source Transformation -Environment variable mapping via `WithEnvTransform()`: -```go -func(path string) string // Return env var name -``` - -### Validation Layers -- Pre-decode: `WithValidator(func(*Config) error)` -- Post-decode: `WithTypedValidator(func(*YourType) error)` - -### File Discovery -Search strategy via `WithFileDiscovery()`: -- CLI flag check → Env var → XDG paths → Current dir \ No newline at end of file +Save normalizes atomic application values to TOML strings, marshals before touching +the target file, then writes/syncs/renames a temporary file. Comment text is captured +by lexer tokens and emitted as a preamble. The codec owns syntax, while Config owns +source precedence, snapshot isolation and live reload publication. diff --git a/doc/audit.md b/doc/audit.md new file mode 100644 index 0000000..3e61379 --- /dev/null +++ b/doc/audit.md @@ -0,0 +1,66 @@ +# Config audit and TOML migration + +This branch updates only lixenwraith/config. It uses Go 1.27.1 and the user's merged +TOML main commit `b59d068ff6a48b86365a65e1121343f672f77a67`, pinned as +`v0.0.0-20260928180512-b59d068ff6a4` because no version tag existed at integration. +Logwisp adoption and other TOML consumers are separate follow-up work. + +## Confirmed defects and fixes + +| Finding | Result | Regression evidence | +| --- | --- | --- | +| Fractions/overflow could pass through weak numeric conversion | Checked TOML conversion and explicit destination-width parsing | `TestCheckedConversionsAndRollback`, `FuzzConfigNumericConversions` | +| Options, input containers and returned values could alias caller state | Owned options/values and detached reads/clones | `TestOptionsAreOwned`, `TestOwnedValuesAndSnapshots` | +| Updating the cached target changed old AsStruct results | Immutable cached result and a fresh snapshot per call | `TestOwnedValuesAndSnapshots` | +| ExportEnv compared uncomparable slices/maps directly | Deep equality, with callbacks outside Config locks | `TestExportEnvContainerValues` | +| Invalid sources could be published before decoding failed | Whole-source validation before mutation | `TestSourceReplacementIsTransactional` | +| Removed env/CLI values survived subsequent loads | Successful loads replace their complete source | `TestSourceReplacementIsTransactional` | +| A timed-out reload kept running and could publish after stop | Context, watcher identity, path and generation checked at commit | `TestReloadCannotPublishAfterStopTimeoutOrNewLoad` | +| A timeout could suppress retries of unchanged content | Retry timeouts while deduplicating their notifications | `TestWatcherRetriesTimedOutUnchangedContent` | +| Debounce did not cover errors from partial in-place writes | Errors and successful updates wait for a quiet interval | `TestWatcherDebouncesErrorsAndRecovers` | +| Metadata-only polling missed some replacements/edits | Content fingerprints on every poll; deletion/recreation recovery | `TestWatcherSameMetadataReplacementAndRecreation` | +| Source saving lost comments and widened permissions | Comment preamble, existing modes retained, new files 0600 | `TestSaveCommentsPermissionsAndFailure` | +| Atomic types were inconsistently registered/serialized | Exact type matching for duration/time/IP/CIDR/URL and pointer forms | `TestAtomicValuesAndFileRoundTrip` | +| A missing file could hide a joined invalid CLI/environment error | Missing-file fallback only when other sources succeed | `TestBuilderDoesNotHideInvalidSourceBehindMissingFile` | +| Required registration was a no-op; overlapping/duplicate fields were ambiguous | Required paths validated at Build; staged registration rejects conflicts | `TestRegistrationAndRequiredContracts` | +| Default source selection and narrow target schemas could be inconsistent | Default precedence honored; explicit defaults must match the typed target | `TestRegistrationAndRequiredContracts` | + +Reproduction against original main `ccd280b`: fractional-to-integer conversion +silently produced 1 from 1.5; modifying an input slice changed its registered +default; an out-of-range CLI batch returned success; and requesting a refreshed +AsStruct changed an earlier result under its reader. All four were reproduced with +public-API probes; committed regressions cover the corrected behavior. + +## Dependency and scope decision + +Mapstructure is replaceable for the package's actual object model. The replacement +supports TOML field names, ordinary containers, pointers and the existing standard +atomic types. It does not reproduce mapstructure's broad weak coercions or add a +custom hook API. Numeric correctness is shared with lixenwraith/toml. + +`go list -deps ./...` reports only the standard library, lixenwraith/toml and this +module. BurntSushi TOML, mapstructure and YAML are removed from runtime code. +Testify remains test-only, so its transitive YAML dependency still appears in go.mod +and go.sum. JSON/YAML files and tags are unsupported, with explicit rejection tests. + +No global reflection cache or lock was introduced. Known container sizes are used +for allocation. Stored values are immutable after admission. One watcher worker +owns observation/debounce state, and subscription cleanup has no per-subscriber +goroutine. File I/O and environment callbacks execute outside Config locks. + +## Verification and limits + +The full unit and race suites and go vet pass with Go 1.27.1. Numeric fuzzing ran +for 10 seconds and passed 234,155 executions, covering full uint64 preservation, +signed boundaries and narrowing. The 386 and Windows test binaries cross-compile +locally. This host cannot execute 386 binaries; CI executes that suite and also +runs race tests, vet, fuzzing and Windows compilation. Final checks are on the PR. + +TOML's supported subset is unchanged. Native datetime syntax is not added; time.Time +is represented by a quoted RFC3339Nano string. Full uint64 works in memory but values +above MaxInt64 cannot be saved as TOML integers. Comment text survives as a preamble; +locations and formatting do not. Unknown file keys are not saved. Depth is bounded +at 128 for configuration graphs. Live application validators remain an application +responsibility; Builder validators run only during initialization. Notifications +are best effort, and file timeouts prevent late publication without forcibly +interrupting kernel I/O. See the linked topic guides for migration details. diff --git a/doc/builder.md b/doc/builder.md index 4b6a3f1..9345c7d 100644 --- a/doc/builder.md +++ b/doc/builder.md @@ -1,373 +1,32 @@ -# Builder Pattern - -The builder pattern provides fine-grained control over configuration initialization and loading behavior. - -## Basic Builder Usage - -```go -cfg, err := config.NewBuilder(). - WithDefaults(defaultStruct). - WithEnvPrefix("MYAPP_"). - WithFile("config.toml"). - Build() -``` - -## Builder Methods - -### WithDefaults - -Register a struct containing default values: - -```go -type Config struct { - Host string `toml:"host"` - Port int `toml:"port"` -} - -defaults := &Config{ - Host: "localhost", - Port: 8080, -} - -cfg, _ := config.NewBuilder(). - WithDefaults(defaults). - Build() -``` - -### WithTarget - -Enable type-aware mode with automatic struct population: - -```go -var appConfig Config - -cfg, _ := config.NewBuilder(). - WithTarget(&appConfig). // Registers struct and enables AsStruct() - WithFile("config.toml"). - Build() - -// Access populated struct -populated, _ := cfg.AsStruct() -config := populated.(*Config) -``` - -### WithTagName - -Use different struct tags for field mapping: - -```go -type Config struct { - Server struct { - Host string `json:"host"` // Using JSON tags - Port int `json:"port"` - } `json:"server"` -} - -cfg, _ := config.NewBuilder(). - WithDefaults(&Config{}). - WithTagName("json"). // Use json tags instead of toml - Build() -``` - -Supported tag names: `toml` (default), `json`, `yaml` - -### WithPrefix - -Add a prefix to all registered paths: - -```go -cfg, _ := config.NewBuilder(). - WithDefaults(serverConfig). - WithPrefix("server"). // All paths prefixed with "server." - Build() - -// Access as "server.host" instead of just "host" -host, _ := cfg.Get("server.host") -``` - -### WithEnvPrefix - -Set environment variable prefix: - -```go -cfg, err := config.NewBuilder(). - WithEnvPrefix("MYAPP_"). - Build() - -// Reads from MYAPP_SERVER_PORT for "server.port" -``` - -### WithSources - -Configure source precedence order: - -```go -// Environment variables take highest priority -cfg, _ := config.NewBuilder(). - WithSources( - config.SourceEnv, - config.SourceFile, - config.SourceCLI, - config.SourceDefault, - ). - Build() -``` - -### WithEnvTransform - -Custom environment variable name mapping: - -```go -cfg, _ := config.NewBuilder(). - WithEnvTransform(func(path string) string { - // Custom mapping logic - switch path { - case "server.port": - return "PORT" // Use $PORT instead of $MYAPP_SERVER_PORT - case "database.url": - return "DATABASE_URL" - default: - // Default transformation - return "MYAPP_" + strings.ToUpper( - strings.ReplaceAll(path, ".", "_"), - ) - } - }). - Build() -``` - -### WithEnvWhitelist - -Limit which configuration paths check environment variables: - -```go -cfg, _ := config.NewBuilder(). - WithEnvWhitelist( - "server.port", - "database.url", - "api.key", - ). // Only these paths read from env - Build() -``` - -### WithValidator - -Add validation functions that run *before* the target struct is populated. These validators operate on the raw `*config.Config` object and are suitable for checking required paths or formats before type conversion. - -```go -// Validator runs on raw, pre-decoded values. -cfg, _ := config.NewBuilder(). - WithDefaults(defaults). - WithValidator(func(c *config.Config) error { - // Validate port range - port, _ := c.Get("server.port") - if p := port.(int64); p < 1024 || p > 65535 { - return fmt.Errorf("port must be between 1024-65535") - } - return nil - }). - WithValidator(func(c *config.Config) error { - // Validate required fields - return c.Validate("api.key", "database.url") - }). - Build() -``` - -For type-safe validation, see `WithTypedValidator`. - -### WithTypedValidator - -Add a type-safe validation function that runs *after* the configuration has been fully loaded and decoded into the target struct (set by `WithTarget`). This is the recommended approach for most validation logic. - -The validation function must accept a single argument: a pointer to the same struct type that was passed to `WithTarget`. -```go -type AppConfig struct { - Server struct { - Port int64 `toml:"port"` - } `toml:"server"` -} - -var target AppConfig - -cfg, err := config.NewBuilder(). - WithTarget(&target). - WithFile("config.toml"). - WithTypedValidator(func(conf *AppConfig) error { - if conf.Server.Port < 1024 || conf.Server.Port > 65535 { - return fmt.Errorf("port %d is outside the valid range", conf.Server.Port) - } - return nil - }). - Build() -``` - -### WithFile - -Set configuration file path: - -```go -cfg, _ := config.NewBuilder(). - WithFile("/etc/myapp/config.toml"). - Build() -``` - -### WithArgs - -Override command-line arguments (default is os.Args[1:]): - -```go -cfg, _ := config.NewBuilder(). - WithArgs([]string{"--debug", "--server.port=9090"}). - Build() -``` - -### WithFileDiscovery - -Enable automatic configuration file discovery: - -```go -cfg, _ := config.NewBuilder(). - WithFileDiscovery(config.FileDiscoveryOptions{ - Name: "myapp", - Extensions: []string{".toml", ".conf"}, - EnvVar: "MYAPP_CONFIG", - CLIFlag: "--config", - UseXDG: true, - }). - Build() -``` - -This searches for configuration files in: -1. Path specified by `--config` flag -2. Path in `$MYAPP_CONFIG` environment variable -3. Current directory -4. XDG config directories (`~/.config/myapp/`, `/etc/myapp/`) - -## Method Interaction and Precedence - -While most builder methods can be chained in any order, it's important to understand how `WithDefaults` and `WithTarget` interact to define the default configuration values. - -### `WithDefaults` Has Precedence - -**Rule:** If `WithDefaults()` is used anywhere in the chain, it will **always** be the definitive source for default values. - -This is the recommended approach for clarity and explicitness. It cleanly separates the struct that defines the defaults from the struct that will be populated. - -**Example (Recommended Pattern):** - -```go -// initialData contains the fallback values. -initialData := &AppConfig{ - Server: ServerConfig{Port: 8080}, -} - -// target is an empty shell for population. -var target AppConfig - -// WithDefaults explicitly sets the defaults. -// WithTarget sets up the config for type-safe decoding. -cfg, err := config.NewBuilder(). - WithTarget(&target). - WithDefaults(initialData). - WithFile("config.toml"). - Build() -``` - -In this scenario, the `target` struct is *only* used for type information and `AsStruct()` functionality; its initial (zero) values are not used as defaults as per below. - -### Using `WithTarget` for Defaults - -**Rule:** If `WithDefaults()` is **not** used, the struct passed to `WithTarget()` will serve as the source of default values. - -This provides a convenient shorthand for simpler cases where the initial state of your application's config struct *is* the desired default state. The unit tests for the package rely on this behavior. - -**Example (Convenience Pattern):** - -```go -// The initial state of this struct will be used as the defaults. -target := &AppConfig{ - Server: ServerConfig{Port: 8080}, -} - -// Since WithDefaults() is absent, the builder uses `target` -// for both defaults and for type-safe decoding. -cfg, err := config.NewBuilder(). - WithTarget(&target). - WithFile("config.toml"). - Build() -``` - -## Usage Patterns - -### Type-Safe Configuration Access - -```go -type AppConfig struct { - Server ServerConfig `toml:"server"` - DB DBConfig `toml:"database"` -} - -var conf AppConfig - -cfg, _ := config.NewBuilder(). - WithTarget(&conf). - WithFile("config.toml"). - Build() - -// Direct struct access after building -fmt.Printf("Port: %d\n", conf.Server.Port) - -// Or get updated struct anytime -latest, _ := cfg.AsStruct() -appConf := latest.(*AppConfig) -``` - -### Multi-Stage Validation - -```go -cfg, err := config.NewBuilder(). - WithDefaults(defaults). - // Stage 1: Validate structure - WithValidator(validateStructure). - // Stage 2: Validate values - WithValidator(validateRanges). - // Stage 3: Validate relationships - WithValidator(validateRelationships). - Build() - -func validateStructure(c *config.Config) error { - required := []string{"server.host", "server.port", "database.url"} - return c.Validate(required...) -} - -func validateRanges(c *config.Config) error { - port, _ := c.Get("server.port") - if p := port.(int64); p < 1 || p > 65535 { - return fmt.Errorf("invalid port: %d", p) - } - return nil -} - -func validateRelationships(c *config.Config) error { - // Validate that related values make sense together - // e.g., if SSL is enabled, ensure cert paths are set - return nil -} -``` - -### Error Handling - -The builder accumulates errors and returns them on `Build()`: - -```go -cfg, err := config.NewBuilder(). - WithTarget(nil). // Error: nil target - WithTagName("invalid"). // Error: unsupported tag - Build() - -if err != nil { - // err contains first error encountered -} -``` - -For panic on error use `MustBuild()` \ No newline at end of file +# Builder + +`NewBuilder` collects defaults, an optional typed target, file, environment and CLI +settings. Build registers defaults, loads each configured source, checks required +paths, runs Config validators and then typed validators. Missing files are nonfatal +only when other loading and validation succeeds. + +| Option | Purpose | +| --- | --- | +| `WithDefaults(value)` | Explicit struct defaults | +| `WithTarget(&value)` | Target type and fallback defaults; filled once by Build | +| `WithPrefix("section")` | Prefix registered paths and the typed snapshot section | +| `WithFile(path)` | TOML file; missing path can be watched for later creation | +| `WithFileDiscovery(options)` | Search CLI, environment and configured directories | +| `WithEnvPrefix`, `WithEnvTransform`, `WithEnvWhitelist` | Environment mapping | +| `WithArgs(args)` | Explicit CLI input; nil disables it | +| `WithSources(...)` | Source precedence; first wins | +| `WithSecurityOptions(options)` | File traversal, owner and size checks | +| `WithValidator(fn)` | Build-time validation using Config accessors | +| `WithTypedValidator(func(*T) error)` | Build-time validation of the target snapshot | + +Only TOML tags and file format are supported. WithTagName accepts `toml`; +WithFileFormat accepts `toml` or `auto`. JSON/YAML modes are removed. + +Use a builder from one goroutine during initialization. Runtime updates go through +Config methods. `AsStruct` returns independent snapshots and never mutates the +original target after Build. Validators run during Build, not on every later Set +or reload; later changes are always checked against registered value types. + +Explicit defaults must use the same struct type as the target. `WithDefaults` +takes precedence over the initial target values. A typed nil validator is ignored; +signatures must match the target pointer and return error. diff --git a/doc/cli.md b/doc/cli.md index aa78aee..e24ce22 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -1,188 +1,19 @@ -# Command Line Arguments +# Command-line arguments -The config package supports command-line argument parsing with flexible formats and automatic type conversion. +`LoadCLI` accepts `--server.port=9000`, `--server.port 9000`, and bare boolean flags +such as `--debug`. Arguments after `--` are positional and are not parsed. Other +positional arguments are ignored. There is no short-flag parser; use Go's flag package +when that interface is needed. Paths must obey registration's dot-separated key syntax. -## Argument Formats +Values remain strings and are validated against registered types before publication. +Each successful call replaces the complete CLI source, including an empty argument +list. Omitted flags lose their previous overrides. Unknown paths are recorded in +`UnknownCLIKeys` in sorted order and are not registered. The returned list is a copy. -### Key-Value Pairs +`GenerateFlags` creates a flag.FlagSet from defaults. `BindFlags` atomically replaces +the CLI source with explicitly visited flags, rejects unregistered flags, and uses +the same checked conversions. Repeated flags resolve to the last value. Invalid +values leave the prior CLI source and unknown-key list intact. -```bash -# Space-separated -./myapp --server.port 8080 --database.url "postgres://localhost/db" - -# Equals-separated -./myapp --server.port=8080 --database.url=postgres://localhost/db - -# Mixed formats -./myapp --server.port 8080 --debug=true -``` - -### Boolean Flags - -```bash -# Boolean flags don't require a value (assumed true) -./myapp --debug --verbose - -# Explicit boolean values -./myapp --debug=true --verbose=false -``` - -### Nested Paths - -Use dot notation for nested configuration: - -```bash -./myapp --server.host=0.0.0.0 --server.port=9090 --server.tls.enabled=true -``` - -## Type Conversion - -Command-line values are automatically converted to match registered types: - -```go -type Config struct { - Port int64 `toml:"port"` - Timeout time.Duration `toml:"timeout"` - Ratio float64 `toml:"ratio"` - Enabled bool `toml:"enabled"` - Tags []string `toml:"tags"` -} - -// All these are parsed correctly: -// --port=8080 → int64(8080) -// --timeout=30s → time.Duration(30 * time.Second) -// --ratio=0.95 → float64(0.95) -// --enabled=true → bool(true) -// --tags=prod,stable → []string{"prod", "stable"} -``` - -## Integration with flag Package - -### Generate flag.FlagSet - -```go -// Generate flags from registered configuration -fs := cfg.GenerateFlags() - -// Parse command line -if err := fs.Parse(os.Args[1:]); err != nil { - log.Fatal(err) -} - -// Apply parsed flags to configuration -if err := cfg.BindFlags(fs); err != nil { - log.Fatal(err) -} -``` - -### Custom Flag Registration - -```go -fs := flag.NewFlagSet("myapp", flag.ContinueOnError) - -// Add custom flags -verbose := fs.Bool("v", false, "verbose output") -configFile := fs.String("config", "config.toml", "config file path") - -// Parse -fs.Parse(os.Args[1:]) - -// Use custom flags -if *verbose { - log.SetLevel(log.DebugLevel) -} - -// Load config with custom file path -cfg, _ := config.NewBuilder(). - WithFile(*configFile). - Build() - -// Bind remaining flags -cfg.BindFlags(fs) -``` - -## Precedence and Overrides - -Command-line arguments have the highest precedence by default: - -```go -// Default precedence: CLI > Env > File > Default -cfg, _ := config.Quick(defaults, "APP_", "config.toml") - -// Even if config.toml sets port=8080 and APP_PORT=9090, -// --port=7070 will win -``` - -Change precedence if needed: - -```go -cfg, _ := config.NewBuilder(). - WithSources( - config.SourceEnv, // Env highest - config.SourceCLI, // Then CLI - config.SourceFile, // Then file - config.SourceDefault, // Finally defaults - ). - Build() -``` - -## Argument Parsing Details - -### Validation - -- Paths must use valid identifiers (letters, numbers, underscore, dash) -- No leading/trailing dots in paths -- Empty segments not allowed (no `..` in paths) - -### Special Cases - -```bash -# Double dash stops flag parsing -./myapp --port=8080 -- --not-a-flag - -# Single dash flags are ignored (not GNU-style) -./myapp -p 8080 # Ignored, use --port - -# Quoted values preserve spaces -./myapp --message="Hello World" --name='John Doe' - -# Escape quotes in values -./myapp --json="{\"key\": \"value\"}" -``` - -### Value Parsing Rules - -1. **Booleans**: `true`, `false` (case-sensitive) -2. **Numbers**: Standard decimal notation -3. **Strings**: Quoted or unquoted (quotes removed if present) -4. **Lists**: Comma-separated (when target type is slice) - -## Override Arguments - -```go -// Parse custom arguments instead of os.Args -customArgs := []string{"--debug", "--port=9090"} - -cfg, _ := config.NewBuilder(). - WithArgs(customArgs). - Build() -``` - -## Error Handling - -CLI parsing errors are returned from `Build()` or `LoadCLI()`: - -```go -cfg, err := config.NewBuilder(). - WithDefaults(&Config{}). - Build() - -if err != nil { - switch { - case errors.Is(err, config.ErrCLIParse): - log.Fatal("Invalid command line arguments:", err) - default: - log.Fatal("Configuration error:", err) - } -} -``` \ No newline at end of file +The Builder reads os.Args by default. Use `WithArgs(nil)` to disable CLI loading for +that build or supply an explicit argument slice for deterministic initialization. diff --git a/doc/env.md b/doc/env.md index 8f7cfaa..80cac28 100644 --- a/doc/env.md +++ b/doc/env.md @@ -1,202 +1,22 @@ -# Environment Variables +# Environment variables -The config package provides flexible environment variable support with automatic name transformation, custom mappings, and whitelist capabilities. +`LoadEnv("APP_")` maps `server.port` to `APP_SERVER_PORT`. It replaces the complete +environment source: variables removed since the previous load lose their overrides. +Set `LoadOptions.EnvTransform` for custom names and `EnvWhitelist` to limit paths. +Options are copied. Custom transforms run outside Config locks and may inspect Config. -## Basic Usage +Environment values are stored as strings and validated against registered types +before any part of the replacement is published. Numbers use decimal text; durations +use Go duration syntax; timestamps use RFC3339Nano-compatible text. Slices and arrays +use comma-separated elements without an escaping convention. Empty text gives an +empty slice. Values larger than MaxValueSize are rejected. -Environment variables are automatically mapped from configuration paths: +`RegisterWithEnv` and a struct field's `env` tag load an explicitly named variable +during registration. Later `LoadEnv` calls use the configured transform/whitelist, +so explicit registration-time mappings are not automatically repeated on reload. -```go -cfg, _ := config.Quick(defaults, "MYAPP_", "config.toml") - -// These environment variables are automatically loaded: -// MYAPP_SERVER_PORT → server.port -// MYAPP_DATABASE_URL → database.url -// MYAPP_LOG_LEVEL → log.level -// MYAPP_FEATURES_ENABLED → features.enabled -``` - -## Name Transformation - -### Default Transformation - -By default, paths are transformed as follows: -1. Dots (`.`) become underscores (`_`) -2. Converted to uppercase -3. Prefix is prepended - -```go -// Path transformations: -// server.port → MYAPP_SERVER_PORT -// database.url → MYAPP_DATABASE_URL -// tls.cert.path → MYAPP_TLS_CERT_PATH -// maxRetries → MYAPP_MAXRETRIES -``` - -### Custom Transformation - -Define custom environment variable mappings: - -```go -cfg, _ := config.NewBuilder(). - WithEnvTransform(func(path string) string { - switch path { - case "server.port": - return "PORT" // Use $PORT directly - case "database.url": - return "DATABASE_URL" - case "api.key": - return "API_KEY" - default: - // Fallback to default transformation - return "MYAPP_" + strings.ToUpper( - strings.ReplaceAll(path, ".", "_"), - ) - } - }). - Build() -``` - -### No Transformation - -Return empty string to skip environment lookup: - -```go -cfg, _ := config.NewBuilder(). - WithEnvTransform(func(path string) string { - // Only allow specific env vars - allowed := map[string]string{ - "port": "PORT", - "database": "DATABASE_URL", - } - return allowed[path] // Empty string if not in map - }). - Build() -``` - -## Explicit Environment Variable Mapping - -Use the `env` struct tag for explicit mappings: - -```go -type Config struct { - Port int `toml:"port" env:"PORT"` - Database string `toml:"database" env:"DATABASE_URL"` - APIKey string `toml:"api_key" env:"API_KEY"` -} - -// These use the explicit env tag names, ignoring prefix -cfg, _ := config.NewBuilder(). - WithDefaults(&Config{}). - WithEnvPrefix("MYAPP_"). // Not used for tagged fields - Build() -``` - -Or register with explicit environment variable: - -```go -cfg.RegisterWithEnv("server.port", 8080, "PORT") -cfg.RegisterWithEnv("database.url", "localhost", "DATABASE_URL") -``` - -## Using the `env` Tag - -Structs can specify explicit environment variable names using the `env` tag: - -```go -type Config struct { - Server struct { - Port int `toml:"port" env:"PORT"` // Uses $PORT - Host string `toml:"host" env:"SERVER_HOST"` // Uses $SERVER_HOST - } `toml:"server"` -} -``` - -## Environment Variable Whitelist - -Limit which paths can be set via environment: - -```go -cfg, _ := config.NewBuilder(). - WithEnvWhitelist( - "server.port", - "database.url", - "api.key", - "log.level", - ). // Only these paths read from environment - Build() -``` - -## Type Conversion - -Environment variables (strings) are automatically converted to the registered type: - -```bash -# Booleans -export MYAPP_DEBUG=true -export MYAPP_VERBOSE=false - -# Numbers -export MYAPP_PORT=8080 -export MYAPP_TIMEOUT=30 -export MYAPP_RATIO=0.95 - -# Durations -export MYAPP_TIMEOUT=30s -export MYAPP_INTERVAL=5m - -# Lists (comma-separated) -export MYAPP_TAGS=prod,stable,v2 -``` - -## Manual Environment Loading - -Load environment variables at any time: - -```go -cfg := config.New() -cfg.RegisterStruct("", &Config{}) - -// Load with prefix -if err := cfg.LoadEnv("MYAPP_"); err != nil { - log.Fatal(err) -} - -// Or use existing options -if err := cfg.LoadWithOptions("", nil, config.LoadOptions{ - EnvPrefix: "MYAPP_", - Sources: []config.Source{config.SourceEnv}, -}); err != nil { - log.Fatal(err) -} -``` - -## Discovering Environment Variables - -Find which environment variables are set: - -```go -// Discover all env vars matching registered paths -discovered := cfg.DiscoverEnv("MYAPP_") - -for path, envVar := range discovered { - log.Printf("%s is set via %s", path, envVar) -} -``` - -## Precedence Examples - -Default precedence: CLI > Env > File > Default - -Custom precedence (Env > File > CLI > Default): - -```go -cfg, _ := config.NewBuilder(). - WithSources( - config.SourceEnv, - config.SourceFile, - config.SourceCLI, - config.SourceDefault, - ). - Build() -``` \ No newline at end of file +`DiscoverEnv(prefix)` returns registered paths with present variable names. +`ExportEnv(prefix)` returns formatted effective values that differ from defaults; +it safely handles containers. It is diagnostic output, not a general lossless +serialization format for maps, slices or strings containing delimiters. Use Save +for round-trip persistence. diff --git a/doc/file.md b/doc/file.md index ad85808..728c5b6 100644 --- a/doc/file.md +++ b/doc/file.md @@ -1,304 +1,34 @@ -# Configuration Files +# TOML files and saves -The config package supports TOML configuration files with automatic loading, discovery, and atomic saving. +`LoadFile(path)` parses TOML regardless of the usual TOML/conf/config extension; +JSON/YAML extensions are rejected. `SetFileFormat` and `WithFileFormat` accept +`toml` and the compatibility alias `auto`; neither enables format detection. +Only registered paths are applied. Registered map fields accept whole tables. +Replacing a registered parent table with a scalar is an error. A missing key +removes that source override, exposing the next source or registered default. -## TOML Format +Loads parse and validate before publication. Syntax errors, incompatible values, +overflow and read failures leave prior values, comments and the loaded path intact. +Readers must be regular files. Security options check ownership of the opened file +on Unix and enforce size limits both before and during reading. Ownership checks +fail explicitly on unsupported platforms. PreventPathTraversal rejects relative +paths escaping through `..`; it is not a symlink sandbox. Absolute paths remain +allowed. A zero MaxFileSize means no configured byte limit. -TOML (Tom's Obvious, Minimal Language) is the supported configuration format: +`Save(path)` writes effective values; `SaveSource(path, source)` writes one source. +Default selects registered defaults. Both use a temporary file in the destination +directory, sync it, and rename it over the destination. They preserve an existing +file's permission bits and use 0600 for a new file. They do not promise portable +power-loss durability of the directory entry or platform-independent replacement +of open files. Encoding failures occur before the destination is changed. -```toml -# Basic values -host = "localhost" -port = 8080 -debug = false +Loaded comment text, including inline comments, is preserved as a preamble. Hashes +inside strings are not comments. Formatting and comment positions are standardized, +and unknown/unregistered keys are omitted. Saves do not merge concurrent edits made +outside Config after the last successful load. Save does not switch the watched path. -# Nested sections -[server] -host = "0.0.0.0" -port = 9090 -timeout = "30s" - -[database] -url = "postgres://localhost/mydb" -max_conns = 25 -timeout = "5s" - -# Arrays -[features] -enabled = ["auth", "api", "metrics"] - -# Inline tables -tls = { enabled = true, cert = "/path/to/cert", key = "/path/to/key" } -``` - -## Loading Configuration Files - -### Basic Loading - -```go -cfg := config.New() -cfg.RegisterStruct("", &Config{}) - -if err := cfg.LoadFile("config.toml"); err != nil { - if errors.Is(err, config.ErrConfigNotFound) { - log.Println("Config file not found, using defaults") - } else { - log.Fatal("Failed to load config:", err) - } -} -``` - -### With Builder - -```go -cfg, err := config.NewBuilder(). - WithDefaults(&Config{}). - WithFile("/etc/myapp/config.toml"). - Build() -``` - -### Multiple File Attempts - -```go -// Try multiple locations -locations := []string{ - "./config.toml", - "~/.config/myapp/config.toml", - "/etc/myapp/config.toml", -} - -var cfg *config.Config -var err error - -for _, path := range locations { - cfg, err = config.NewBuilder(). - WithDefaults(&Config{}). - WithFile(path). - Build() - - if err == nil || !errors.Is(err, config.ErrConfigNotFound) { - break - } -} -``` - -## Automatic File Discovery - -Use file discovery to find configuration automatically: - -```go -cfg, _ := config.NewBuilder(). - WithDefaults(&Config{}). - WithFileDiscovery(config.FileDiscoveryOptions{ - Name: "myapp", - Extensions: []string{".toml", ".conf"}, - EnvVar: "MYAPP_CONFIG", - CLIFlag: "--config", - UseXDG: true, - UseCurrentDir: true, - Paths: []string{"/opt/myapp"}, - }). - Build() -``` - -Search order: -1. CLI flag: `--config=/path/to/config.toml` -2. Environment variable: `$MYAPP_CONFIG` -3. Current directory: `./myapp.toml`, `./myapp.conf` -4. XDG config: `~/.config/myapp/myapp.toml` -5. System paths: `/etc/myapp/myapp.toml` -6. Custom paths: `/opt/myapp/myapp.toml` - -## Saving Configuration - -### Save Current State - -```go -// Save all current values atomically -if err := cfg.Save("config.toml"); err != nil { - log.Fatal("Failed to save config:", err) -} -``` - -The save operation is atomic - it writes to a temporary file then renames it. - -### Save Specific Source - -```go -// Save only values from environment variables -if err := cfg.SaveSource("env-config.toml", config.SourceEnv); err != nil { - log.Fatal(err) -} - -// Save only file-loaded values -if err := cfg.SaveSource("file-only.toml", config.SourceFile); err != nil { - log.Fatal(err) -} -``` - -### Generate Default Configuration - -```go -// Create a default config file -defaults := &Config{} -// ... set default values ... - -cfg, _ := config.NewBuilder(). - WithDefaults(defaults). - Build() - -// Save defaults as config template -if err := cfg.SaveSource("config.toml.example", config.SourceDefault); err != nil { - log.Fatal(err) -} -``` - -## File Structure Mapping - -TOML structure maps directly to dot-notation paths: - -```toml -# Maps to "debug" -debug = true - -[server] -# Maps to "server.host" -host = "localhost" -# Maps to "server.port" -port = 8080 - -[server.tls] -# Maps to "server.tls.enabled" -enabled = true -# Maps to "server.tls.cert" -cert = "/path/to/cert" - -[[users]] -# Array elements: "users.0.name", "users.0.role" -name = "admin" -role = "administrator" - -[[users]] -# Array elements: "users.1.name", "users.1.role" -name = "user" -role = "standard" -``` - -## Type Handling - -TOML types map to Go types: - -```toml -# Strings -name = "myapp" -multiline = """ -Line one -Line two -""" - -# Numbers -port = 8080 # int64 -timeout = 30 # int64 -ratio = 0.95 # float64 -max_size = 1_000_000 # int64 (underscores allowed) - -# Booleans -enabled = true -debug = false - -# Dates/Times (RFC 3339) -created_at = 2024-01-15T09:30:00Z -expires = 2024-12-31 - -# Arrays -ports = [8080, 8081, 8082] -tags = ["production", "stable"] - -# Tables (objects) -[database] -host = "localhost" -port = 5432 - -# Array of tables -[[servers]] -name = "web1" -host = "10.0.0.1" - -[[servers]] -name = "web2" -host = "10.0.0.2" -``` - -## Error Handling - -File loading can produce several error types: - -```go -err := cfg.LoadFile("config.toml") -if err != nil { - switch { - case errors.Is(err, config.ErrConfigNotFound): - // File doesn't exist - often not fatal - log.Println("No config file, using defaults") - - case strings.Contains(err.Error(), "failed to parse TOML"): - // TOML syntax error - log.Fatal("Invalid TOML syntax:", err) - - case strings.Contains(err.Error(), "failed to read"): - // Permission or I/O error - log.Fatal("Cannot read config file:", err) - - default: - log.Fatal("Config error:", err) - } -} -``` - -## Security Considerations - -### File Permissions - -```go -// After saving, verify permissions -info, err := os.Stat("config.toml") -if err == nil { - mode := info.Mode() - if mode&0077 != 0 { - log.Warn("Config file is world/group readable") - // Fix permissions - os.Chmod("config.toml", 0600) - } -} -``` - -### Size Limits - -Files and values have size limits: -- Maximum file size: ~10MB (10 * MaxValueSize) -- Maximum value size: 1MB - -## Partial Loading - -Load only specific sections: - -```go -var serverCfg ServerConfig -if err := cfg.Scan("server", &serverCfg); err != nil { - log.Fatal(err) -} - -var dbCfg DatabaseConfig -if err := cfg.Scan("database", &dbCfg); err != nil { - log.Fatal(err) -} -``` - -## Best Practices - -1. **Use Example Files**: Generate `.example` files with defaults -2. **Check Permissions**: Ensure config files aren't world-readable -3. **Validate After Load**: Add validators to check loaded values -4. **Handle Missing Files**: Missing config files often aren't fatal -5. **Use Atomic Saves**: The built-in Save method is atomic -6. **Document Structure**: Comment your TOML files thoroughly \ No newline at end of file +Durations and atomic time/network values are saved as strings and decoded back to +their registered types. time.Time uses RFC3339Nano; native TOML datetime syntax is +unsupported. Integers in files have signed 64-bit range. Full uint64 values can be +held in memory but cannot be saved above MaxInt64. Unsupported TOML syntax and +numeric key restrictions follow the pinned [toml package](https://github.com/lixenwraith/toml). diff --git a/doc/quick-guide_lixenwraith_config.md b/doc/quick-guide_lixenwraith_config.md deleted file mode 100644 index 8e0fa07..0000000 --- a/doc/quick-guide_lixenwraith_config.md +++ /dev/null @@ -1,159 +0,0 @@ -# lixenwraith/config Quick Reference Guide - -This guide details the `lixenwraith/config` package for thread-safe Go configuration. -It supports multiple sources (files, environment, CLI), type-safe struct population, and live reconfiguration. - -## Quick Start: Recommended Usage - -The recommended pattern uses the **Builder** with a **target struct** providing compile-time type safety and eliminating the need for runtime type assertions - -```go -package main - -import ( - "fmt" - "log" - "os" - - "github.com/lixenwraith/config" -) - -// 1. Define your application's configuration struct -type AppConfig struct { - Server struct { - Host string `toml:"host"` - Port int `toml:"port"` - } `toml:"server"` - Debug bool `toml:"debug"` -} - -func main() { - // 2. Create a target instance with defaults - // Method A: Direct initialization (cleaner for simple defaults) - target := &AppConfig{} - target.Server.Host = "localhost" - target.Server.Port = 8080 - - // Method B: Use WithDefaults() for explicit separation (shown below) - - // 3. Use the builder to configure and load all sources - cfg, err := config.NewBuilder(). - WithTarget(target). // Enable type-safe mode and register struct fields - // WithDefaults(&AppConfig{...}), // Optional: Override defaults from WithTarget - WithFile("config.toml"). // Load from file (supports .toml, .json, .yaml) - WithEnvPrefix("APP_"). // Load from environment (e.g., APP_SERVER_PORT) - WithArgs(os.Args[1:]). // Load from command-line flags (e.g., --server.port=9090) - Build() // Build the final config object - if err != nil { - log.Fatalf("Config build failed: %v", err) - } - - // 4. Access the fully populated, type-safe struct - // The `target` variable is now populated with the final merged values - fmt.Printf("Running on %s:%d\n", target.Server.Host, target.Server.Port) - - // Or, retrieve the updated struct at any time (e.g., after a live reload) - latest, _ := cfg.AsStruct() - latestConfig := latest.(*AppConfig) - fmt.Printf("Debug mode: %v\n", latestConfig.Debug) -} -``` - -## Supported Formats: TOML, JSON, YAML - -The package supports multiple file formats. The format is auto-detected from the file extension (`.toml`, `.json`, `.yaml`, `.yml`) or file content - -* To specify a format explicitly, use `Builder.WithFileFormat("json")` -* The default struct tag for field mapping is `toml`, but can be changed with `Builder.WithTagName("json")` - -## Builder Pattern - -The `Builder` is the primary way to construct a `Config` instance - -```go -// NewBuilder creates a new configuration builder -func NewBuilder() *Builder -// Build finalizes configuration; returns the first of any accumulated errors -func (b *Builder) Build() (*Config, error) -// MustBuild is like Build but panics on fatal errors -func (b *Builder) MustBuild() *Config -``` - -### Builder Methods - -* `WithTarget(target any)`: **(Recommended)** Registers fields from the target struct and allows access via `AsStruct()` -* `WithDefaults(defaults any)`: Explicitly sets a struct containing default values, overriding any default values already in struct from `WithTarget` -* `WithFile(path string)`: Sets the configuration file path -* `WithFileDiscovery(opts FileDiscoveryOptions)`: Enables automatic config file discovery (searches CLI flags, env vars, XDG paths, and current directory) -* `WithArgs(args []string)`: Sets the command-line arguments to parse (e.g., `--server.port=9090`) -* `WithEnvPrefix(prefix string)`: Sets the global environment variable prefix (e.g., `MYAPP_`) -* `WithSources(sources ...Source)`: Overrides the default source precedence order -* `WithTypedValidator(fn any)`: **(Recommended for validation)** Adds a type-safe validation function with signature `func(c *YourConfigType) error` that runs *after* the target struct is populated -* `WithValidator(fn ValidatorFunc)`: Adds a validation function that runs *before* type-safe population, operating on the raw `*Config` object -* `WithTagName(tagName string)`: Sets the primary struct tag for field mapping (`"toml"`, `"json"`, `"yaml"`) -* `WithPrefix(prefix string)`: Adds a prefix to all registered paths from a struct -* `WithFileFormat(format string)`: Explicitly sets the file format (`"toml"`, `"json"`, `"yaml"`, `"auto"`) -* `WithSecurityOptions(opts SecurityOptions)`: Sets security options for file loading (path traversal, file size limits) -* `WithEnvTransform(fn EnvTransformFunc)`: Sets a custom environment variable mapping function -* `WithEnvWhitelist(paths ...string)`: Limits environment variable loading to a specific set of paths - -## Type-Safe Access & Population - -These are the **preferred methods** for accessing configuration data - -* `AsStruct() (any, error)`: After using `Builder.WithTarget()`, this method returns the populated, type-safe target struct - This is the primary way to access config after initialization or live reload -* `Scan(basePath string, target any)`: Populates a struct with values from a specific config path (e.g., `cfg.Scan("server", &serverConf)`) -* `ScanMap(configMap map[string]any, target any, tagName ...string)`: Decodes a map[string]any directly into a target struct, useful for plugins or custom config sources -* `GetTyped[T](c *Config, path string) (T, error)`: Retrieves a single value and decodes it to type `T`, handling type conversion automatically -* `ScanTyped[T](c *Config, basePath ...string) (*T, error)`: A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of type `T` - -## Live Reconfiguration - -Enable automatic reloading of configuration when the source file changes - -* `AutoUpdate()`: Enables file watching and automatic reloading with default options -* `AutoUpdateWithOptions(opts WatchOptions)`: Enables reloading with custom options (e.g., poll interval, debounce) -* `StopAutoUpdate()`: Stops the file watcher -* `Watch() <-chan string`: Returns a channel that receives the paths of changed values -* `WatchFile(filePath string, formatHint ...string)`: Switches the watcher to a new file at runtime -* `IsWatching() bool`: Returns `true` if the file watcher is active - -The watch channel also receives special notifications: `"file_deleted"`, `"permissions_changed"`, `"reload_error:..."` - -## Source Precedence - -The default order of precedence (highest to lowest) is: -1. **CLI**: Command-line arguments (`--server.port=9090`) -2. **Env**: Environment variables (`MYAPP_SERVER_PORT=8888`) -3. **File**: Configuration file (`config.toml`) -4. **Default**: Values registered from a struct - -This order can be changed via `Builder.WithSources()` or `Config.SetPrecedence()` - -## Dynamic / Legacy Value Access - -These methods are for dynamic key-value access that require type assertion and should be **avoided when a type-safe struct can be used** - -* `Get(path string) (any, bool)`: Retrieves the final merged value. The `bool` indicates if the path was registered. Requires a type assertion, e.g., `port := val.(int64)` -* `Set(path string, value any)`: Updates a value in the highest priority source. The path must be registered first -* `GetSource(path string, source Source) (any, bool)`: Retrieves a value from a specific source layer -* `SetSource(path string, source Source, value any)`: Sets a value for a specific source layer - -## API Reference Summary - -### Core Types - -* `Config`: The primary thread-safe configuration manager -* `Source`: A configuration source (`SourceCLI`, `SourceEnv`, `SourceFile`, `SourceDefault`) -* `LoadOptions`: Options for loading configuration from multiple sources -* `Builder`: Fluent API for constructing a `Config` instance - -### Core Methods - -* `New() *Config`: Creates a new `Config` instance. -* `Register(path string, defaultValue any)`: Registers a path with a default value -* `RegisterStruct(prefix string, structWithDefaults any)`: Recursively registers fields from a struct using `toml` tags -* `Validate(required ...string)`: Checks that all specified required paths have been set from a non-default source -* `Save(path string)`: Atomically saves the current merged configuration state to a file -* `Clone() *Config`: Creates a deep copy of the configuration state -* `Debug() string`: Returns a formatted string of all values for debugging \ No newline at end of file diff --git a/doc/quick-start.md b/doc/quick-start.md index 764d5b1..7c13884 100644 --- a/doc/quick-start.md +++ b/doc/quick-start.md @@ -1,215 +1,30 @@ -# Quick Start Guide +# Quick start -This guide gets you up and running with the config package in minutes. - -## Basic Usage - -The simplest way to use the config package is with the `Quick` function: +Use Go 1.27.1 and TOML tags. Register defaults before loading sources: ```go -package main - -import ( - "log" - "github.com/lixenwraith/config" -) - -// Define your configuration structure -type Config struct { - Server struct { - Host string `toml:"host"` - Port int `toml:"port"` - } `toml:"server"` - Database struct { - URL string `toml:"url"` - MaxConns int `toml:"max_conns"` - } `toml:"database"` - Debug bool `toml:"debug"` -} - -func main() { - // Create defaults - defaults := &Config{} - defaults.Server.Host = "localhost" - defaults.Server.Port = 8080 - defaults.Database.URL = "postgres://localhost/mydb" - defaults.Database.MaxConns = 10 - defaults.Debug = false - - // Initialize configuration - cfg, err := config.Quick( - defaults, // Default values from struct - "MYAPP_", // Environment variable prefix - "config.toml", // Configuration file path - ) - if err != nil { - log.Fatal(err) - } - - // Access values - port, _ := cfg.Get("server.port") - dbURL, _ := cfg.Get("database.url") - - log.Printf("Server running on port %d", port.(int64)) - log.Printf("Database URL: %s", dbURL.(string)) -} +cfg := config.New() +if err := cfg.Register("server.port", uint16(8080)); err != nil { return err } +if err := cfg.LoadFile("config.toml"); err != nil { return err } +if err := cfg.LoadEnv("APP_"); err != nil { return err } +if err := cfg.LoadCLI(os.Args[1:]); err != nil { return err } +port, err := config.GetTyped[uint16](cfg, "server.port") ``` -## Configuration Sources - -The package loads configuration from multiple sources in this default order (highest to lowest priority): - -1. **Command-line arguments** - Override everything -2. **Environment variables** - Override file and defaults -3. **Configuration file** - Override defaults -4. **Default values** - Base configuration - -### Command-Line Arguments - -```bash -./myapp --server.port=9090 --debug -``` - -### Environment Variables - -```bash -export MYAPP_SERVER_PORT=9090 -export MYAPP_DATABASE_URL="postgres://prod/mydb" -export MYAPP_DEBUG=true -``` - -### Configuration File (config.toml) +`APP_SERVER_PORT=9000` and `--server.port=9001` address the same registered path. +CLI has highest default priority. TOML values can be configured in a table: ```toml [server] -host = "0.0.0.0" port = 8080 - -[database] -url = "postgres://localhost/mydb" -max_conns = 25 - -debug = false ``` -## Type Safety +For a typed application, use `NewBuilder().WithTarget(&settings)` as shown in the +[README](../README.md). Build fills the supplied target once. Use `AsStruct` to +obtain new snapshots after updates; retaining an old snapshot is safe. -The package uses struct tags to ensure type safety. When you register a struct, the types are enforced: - -```go -// This struct defines the expected types -type Config struct { - Port int64 `toml:"port"` // Must be a number - Host string `toml:"host"` // Must be a string - Debug bool `toml:"debug"` // Must be a boolean -} - -// Type assertions are safe after registration -port, _ := cfg.Get("port") -portNum := port.(int64) // Type matches registration -``` - -## Error Handling - -The package validates types during loading: - -```go -cfg, err := config.Quick(defaults, "APP_", "config.toml") -if err != nil { - // Handle errors like: - // - Invalid TOML syntax - // - Type mismatches (e.g., string value for int field) - // - File permissions issues - log.Fatal(err) -} -``` - -### Error Categories - -The package uses structured error categories for better error handling. Check errors using `errors.Is()`: -```go -if err := cfg.LoadFile("config.toml"); err != nil { - switch { - case errors.Is(err, config.ErrConfigNotFound): - // Config file doesn't exist, use defaults - case errors.Is(err, config.ErrFileAccess): - // Permission denied or file access issues - case errors.Is(err, config.ErrFileFormat): - // Invalid TOML/JSON/YAML syntax - case errors.Is(err, config.ErrTypeMismatch): - // Value type doesn't match registered type - case errors.Is(err, config.ErrValidation): - // Validation failed - default: - log.Fatal(err) - } -} -``` - -See [Errors](./error.go) for the complete list of error categories and description. - -## Common Patterns - -### Required Fields - -```go -// Register required configuration -cfg.RegisterRequired("api.key", "") -cfg.RegisterRequired("database.url", "") - -// Validate all required fields are set -if err := cfg.Validate("api.key", "database.url"); err != nil { - log.Fatal("Missing required configuration:", err) -} -``` - -### Type-Safe Validation - -```go -cfg, _ := config.NewBuilder(). - WithTarget(&Config{}). - WithTypedValidator(func(c *Config) error { - if c.Server.Port < 1024 { - return fmt.Errorf("port must be >= 1024") - } - return nil - }). - Build() -``` - -See [Validation](validator.md) for more validation options. - -### Using Different Struct Tags - -```go -// Use JSON tags instead of default TOML -type Config struct { - Server struct { - Host string `json:"host"` - Port int `json:"port"` - } `json:"server"` -} - -cfg, _ := config.NewBuilder(). - WithTarget(&Config{}). - WithTagName("json"). - WithFile("config.json"). - Build() -``` - -### Checking Value Sources - -```go -// See which source provided a value -port, _ := cfg.Get("server.port") -sources := cfg.GetSources("server.port") - -for source, value := range sources { - log.Printf("server.port from %s: %v", source, value) -} -``` - -## Next Steps - -- [Builder Pattern](builder.md) - Advanced configuration options -- [Access Patterns](access.md) - All ways to get and set values \ No newline at end of file +A missing file returns `ErrConfigNotFound` along with a usable Config when all +other initialization succeeds. Check that category explicitly if a file is +optional. Invalid environment or CLI values remain fatal even when the file is +missing. `MustBuild` and `MustQuick` ignore only a missing file and panic on other +initialization errors. All source loads replace the previous values of that source. diff --git a/doc/reconfiguration.md b/doc/reconfiguration.md index 81034c5..760fe9f 100644 --- a/doc/reconfiguration.md +++ b/doc/reconfiguration.md @@ -1,349 +1,36 @@ -# Live Reconfiguration +# Live reconfiguration -The config package supports automatic configuration reloading when files change, enabling zero-downtime reconfiguration. +`AutoUpdate` starts polling the loaded file. `Watch` starts it if necessary and +subscribes to notifications. Option variants configure the polling interval +(minimum 100ms), debounce, subscriber limit, reload timeout and permission checks. +Options for an already-running watcher stay unchanged; stop/restart to change them. -## Basic File Watching +One worker reads and fingerprints the regular file per poll, including same-size, +same-timestamp edits and atomic replacements. Both successful and erroneous reads +must settle for the debounce interval. Stable errors are reported once per observed +state. Deletion preserves the last valid state, and recreation can reload normally. +This trades one file read per poll for reliable detection of metadata-preserving edits. -### Enable Auto-Update +Before publishing, the worker checks its context, current watcher identity, watched +path and source generation under the Config lock. Type checks and allocation finish +before the final timeout check. Expired, stopped or superseded operations cannot +publish. ReloadTimeout limits publication eligibility; it cannot forcibly interrupt +a kernel filesystem operation. Reads never hold the Config lock. +Timed-out operations retry on later polls even if the content is unchanged; repeated +timeouts for the same observation emit only one notification until recovery. -```go -cfg, _ := config.NewBuilder(). - WithDefaults(&Config{}). - WithFile("config.toml"). - Build() +Events are changed registered paths, or `file_deleted`, `permissions_changed`, +`reload_timeout`, `reload_error:`, and `precedence:`. A path event means +the effective value changed; a file value masked by CLI need not notify. Group/world +permission changes are blocked when VerifyPermissions is enabled; correcting the +permissions allows loading again. Default options enable that check. -// Enable automatic reloading -cfg.AutoUpdate() +Notifications are buffered (10 entries) and best effort; a slow subscriber may miss +events and should read the latest full state. MaxWatchers bounds retained channels. +StopAutoUpdate cancels publication and closes channels synchronously without waiting +under the Config lock. There is no goroutine per subscriber or per timed-out reload. +A new Watch call after stopping creates a new channel and worker. -// Your application continues running -// Config reloads automatically when file changes - -// Stop watching when done -defer cfg.StopAutoUpdate() -``` - -### Watch for Changes - -```go -// Get notified of configuration changes -changes := cfg.Watch() - -go func() { - for path := range changes { - log.Printf("Configuration changed: %s", path) - - // React to specific changes - switch path { - case "server.port": - // Restart server with new port - restartServer() - case "log.level": - // Update log level - updateLogLevel() - } - } -}() -``` - -## Watch Options - -### Custom Watch Configuration - -```go -opts := config.WatchOptions{ - PollInterval: 500 * time.Millisecond, // Check every 500ms - Debounce: 200 * time.Millisecond, // Wait 200ms after changes - MaxWatchers: 50, // Limit concurrent watchers - ReloadTimeout: 10 * time.Second, // Timeout for reload - VerifyPermissions: true, // Security check -} - -cfg.AutoUpdateWithOptions(opts) -``` - -### Watch Without Auto-Update - -```go -// Just watch, don't auto-reload -changes := cfg.WatchWithOptions(config.WatchOptions{ - PollInterval: time.Second, -}) - -// Manually reload when desired -go func() { - for range changes { - if shouldReload() { - cfg.LoadFile("config.toml") - } - } -}() -``` - -## Change Detection - -### Value Changes - -The watcher detects and notifies about: -- New values added -- Existing values modified -- Values removed -- Type changes - -```go -changes := cfg.Watch() - -for path := range changes { - newVal, exists := cfg.Get(path) - if !exists { - log.Printf("Removed: %s", path) - continue - } - - sources := cfg.GetSources(path) - fileVal, hasFile := sources[config.SourceFile] - - log.Printf("Changed: %s = %v (from file: %v)", - path, newVal, hasFile) -} -``` - -### Special Notifications - -```go -changes := cfg.Watch() - -for notification := range changes { - switch notification { - case "file_deleted": - log.Warn("Config file was deleted") - - case "permissions_changed": - log.Error("Config file permissions changed - potential security issue") - - case "reload_timeout": - log.Error("Config reload timed out") - - default: - if strings.HasPrefix(notification, "reload_error:") { - log.Error("Reload error:", notification) - } else { - // Normal path change - handleConfigChange(notification) - } - } -} -``` - -## Debouncing - -Rapid file changes are automatically debounced: - -```go -// Multiple rapid saves to config.toml -// Only triggers one reload after debounce period - -opts := config.WatchOptions{ - PollInterval: 100 * time.Millisecond, - Debounce: 500 * time.Millisecond, // Wait 500ms -} - -cfg.AutoUpdateWithOptions(opts) -``` - -## Permission Monitoring - -```go -opts := config.WatchOptions{ - VerifyPermissions: true, // Enabled by default -} - -cfg.AutoUpdateWithOptions(opts) - -// Detects if file becomes world-writable -changes := cfg.Watch() -for change := range changes { - if change == "permissions_changed" { - // File permissions changed - // Possible security breach - alert("Config file permissions modified!") - } -} -``` - -## Pattern: Reconfiguration - -```go -type Server struct { - cfg *config.Config - listener net.Listener - mu sync.RWMutex -} - -func (s *Server) watchConfig() { - changes := s.cfg.Watch() - - for path := range changes { - switch { - case strings.HasPrefix(path, "server."): - s.scheduleRestart() - - case path == "log.level": - s.updateLogLevel() - - case strings.HasPrefix(path, "feature."): - s.reloadFeatures() - } - } -} - -func (s *Server) scheduleRestart() { - s.mu.Lock() - defer s.mu.Unlock() - - // Graceful restart logic - log.Info("Scheduling server restart for config changes") - // ... drain connections, restart listener ... -} -``` - -## Pattern: Feature Flags - -```go -type FeatureFlags struct { - cfg *config.Config - mu sync.RWMutex -} - -func (ff *FeatureFlags) Watch() { - changes := ff.cfg.Watch() - - for path := range changes { - if strings.HasPrefix(path, "features.") { - feature := strings.TrimPrefix(path, "features.") - enabled, _ := ff.cfg.Get(path) - - log.Printf("Feature %s: %v", feature, enabled) - ff.notifyFeatureChange(feature, enabled.(bool)) - } - } -} - -func (ff *FeatureFlags) IsEnabled(feature string) bool { - ff.mu.RLock() - defer ff.mu.RUnlock() - - val, exists := ff.cfg.Get("features." + feature) - return exists && val.(bool) -} -``` - -## Pattern: Multi-Stage Reload - -```go -func watchConfigWithValidation(cfg *config.Config) { - changes := cfg.Watch() - - for range changes { - // Stage 1: Snapshot current config - backup := cfg.Clone() - - // Stage 2: Validate new configuration - if err := validateNewConfig(cfg); err != nil { - log.Error("Invalid configuration:", err) - continue - } - - // Stage 3: Apply changes - if err := applyConfigChanges(cfg, backup); err != nil { - log.Error("Failed to apply changes:", err) - // Could restore from backup here - continue - } - - log.Info("Configuration successfully reloaded") - } -} -``` - -## Monitoring - -### Watch Status - -```go -// Check if watching is active -if cfg.IsWatching() { - log.Printf("Auto-update is enabled") - log.Printf("Active watchers: %d", cfg.WatcherCount()) -} -``` - -### Resource Management - -```go -// Limit watchers to prevent resource exhaustion -opts := config.WatchOptions{ - MaxWatchers: 10, // Max 10 concurrent watch channels -} - -// Watchers beyond limit receive closed channels -cfg.AutoUpdateWithOptions(opts) -``` - -## Best Practices - -1. **Always Stop Watching**: Use `defer cfg.StopAutoUpdate()` to clean up -2. **Handle All Notifications**: Check for special error notifications -3. **Validate After Reload**: Ensure new config is valid before applying -4. **Use Debouncing**: Prevent reload storms from rapid edits -5. **Monitor Permissions**: Enable permission verification for security -6. **Graceful Updates**: Plan how your app handles config changes -7. **Log Changes**: Audit configuration modifications - -## Limitations - -- File watching uses polling (not inotify/kqueue) -- No support for watching multiple files -- Changes only detected for registered paths -- Reloads entire file (no partial updates) - -## Common Issues - -### Changes Not Detected - -```go -// Ensure path is registered before watching -cfg.Register("new.value", "default") - -// Now changes to new.value will be detected -``` - -### Rapid Reloads - -```go -// Increase debounce to prevent rapid reloads -opts := config.WatchOptions{ - Debounce: 2 * time.Second, // Wait 2s after changes stop -} -``` - -### Memory Leaks - -```go -// Always stop watching to prevent goroutine leaks -watcher := cfg.Watch() - -// Use context for cancellation -ctx, cancel := context.WithCancel(context.Background()) -defer cancel() - -go func() { - for { - select { - case change := <-watcher: - handleChange(change) - case <-ctx.Done(): - return - } - } -}() -``` \ No newline at end of file +`WatchFile` loads a replacement path before replacing the watcher and preserves its +options. Failure leaves the prior watcher and values active. Loading a different +file directly stops the previous watcher; call Watch to watch the new path. diff --git a/doc/validator.md b/doc/validator.md index 364d55a..230e815 100644 --- a/doc/validator.md +++ b/doc/validator.md @@ -1,175 +1,23 @@ # Validation -The config package provides flexible validation through function injection and bundled validators for common scenarios. +Registration defaults define conversion types. Set, SetSource and complete source +loads reject incompatible values before publication. Overflow, fractional integer +conversion, non-finite values and invalid textual duration/time/network values error. +Untyped nil defaults leave the value type open within the supported value graph. -## Validation Stages +`Validate(paths...)` requires a registered path to have a supplied value, even if it +equals the default. `RegisterRequired` and `required:"true"` fields are collected by +`Validate()` with no arguments and by Build. Defaults alone do not satisfy required +inputs. Registration of a struct is staged, so a duplicate/invalid field or invalid +explicit environment value cannot leave a partially registered schema. -The package supports two validation stages when using the Builder pattern: +Build supports `WithValidator(func(*Config) error)` and a matching +`WithTypedValidator(func(*T) error)`. They run at initialization. They are not rerun +on runtime changes: apply application-level constraints before adopting a new +snapshot when those constraints must hold after reconfiguration. -### Pre-Population Validation (`WithValidator`) - -Runs on raw configuration values before struct population: - -```go -cfg, _ := config.NewBuilder(). - WithDefaults(defaults). - WithValidator(func(c *config.Config) error { - // Check required paths exist - return c.Validate("api.key", "database.url") - }). - Build() -``` - -### Post-Population Validation (`WithTypedValidator`) - -Type-safe validation after struct is populated: - -```go -type AppConfig struct { - Server struct { - Port int64 `toml:"port"` - } `toml:"server"` -} - -cfg, _ := config.NewBuilder(). - WithTarget(&AppConfig{}). - WithTypedValidator(func(cfg *AppConfig) error { - // No type assertion needed - cfg.Server.Port is int64 - if cfg.Server.Port < 1024 || cfg.Server.Port > 65535 { - return fmt.Errorf("port %d outside valid range", cfg.Server.Port) - } - return nil - }). - Build() -``` - -## Bundled Validators - -The package includes common validation functions in the `config` package: - -### Numeric Validators - -```go -// Port validates TCP/UDP port range (1-65535) -config.Port(8080) // returns nil - -// Positive validates positive numbers -config.Positive(42) // returns nil -config.Positive(-1) // returns error - -// NonNegative validates non-negative numbers -config.NonNegative(0) // returns nil -config.NonNegative(-5) // returns error - -// Range creates min/max validators -portValidator := config.Range(1024, 65535) -err := portValidator(8080) // returns nil -``` - -### String Validators - -```go -// NonEmpty validates non-empty strings -config.NonEmpty("hello") // returns nil -config.NonEmpty("") // returns error - -// Pattern creates regex validators -emailPattern := config.Pattern(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`) -err := emailPattern("user@example.com") // returns nil - -// URLPath validates URL path format -config.URLPath("/api/v1") // returns nil -config.URLPath("api/v1") // returns error (must start with /) -``` - -### Network Validators - -```go -// IPAddress validates any IP address -config.IPAddress("192.168.1.1") // returns nil -config.IPAddress("::1") // returns nil - -// IPv4Address validates IPv4 only -config.IPv4Address("192.168.1.1") // returns nil -config.IPv4Address("::1") // returns error - -// IPv6Address validates IPv6 only -config.IPv6Address("2001:db8::1") // returns nil -config.IPv6Address("192.168.1.1") // returns error -``` - -### Enum Validators - -```go -// OneOf creates allowed values validator -logLevel := config.OneOf("debug", "info", "warn", "error") -err := logLevel("info") // returns nil -err = logLevel("trace") // returns error -``` - -## Integration Example - -```go -type ServerConfig struct { - Host string `toml:"host"` - Port int64 `toml:"port"` - Mode string `toml:"mode"` -} - -cfg, err := config.NewBuilder(). - WithTarget(&ServerConfig{}). - WithFile("config.toml"). - WithTypedValidator(func(c *ServerConfig) error { - // Use bundled validators - if err := config.IPAddress(c.Host); err != nil { - return fmt.Errorf("invalid host: %w", err) - } - - if err := config.Port(c.Port); err != nil { - return fmt.Errorf("invalid port: %w", err) - } - - modeValidator := config.OneOf("development", "production") - if err := modeValidator(c.Mode); err != nil { - return fmt.Errorf("invalid mode: %w", err) - } - - return nil - }). - Build() -``` - -## Integration with go-playground/validator - -The package works seamlessly with `go-playground/validator`: - -```go -import "github.com/go-playground/validator/v10" - -type ServerConfig struct { - Host string `toml:"host" validate:"required,hostname"` - Port int `toml:"port" validate:"required,min=1024,max=65535"` - - TLS struct { - Enabled bool `toml:"enabled"` - Cert string `toml:"cert" validate:"required_if=Enabled true"` - Key string `toml:"key" validate:"required_if=Enabled true"` - } `toml:"tls"` -} - -// Load configuration -cfg, _ := config.NewBuilder(). - WithDefaults(&ServerConfig{}). - Build() - -// Get populated struct -serverCfg, _ := cfg.AsStruct() - -// Validate with go-playground/validator -validate := validator.New() -if err := validate.Struct(serverCfg); err != nil { - return err // Validation errors -} -``` - -Tags don't conflict since each package uses different tag names (`toml`/`json`/`yaml` for config, `validate` for validator). \ No newline at end of file +Helpers include Port, Positive, NonNegative, IPAddress, IPv4Address, IPv6Address, +URLPath, OneOf, Range, Pattern and NonEmpty. Pattern compiles its expression when the +validator is created and panics on an invalid expression. Network validators allow +the documented empty/unspecified defaults. Use errors.Is with ErrValidation, +ErrTypeMismatch and ErrDecode to handle failures. diff --git a/dynamic_test.go b/dynamic_test.go index d619452..8f42652 100644 --- a/dynamic_test.go +++ b/dynamic_test.go @@ -1,234 +1,43 @@ -// FILE: lixenwraith/config/dynamic_test.go package config import ( - "encoding/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "os" "path/filepath" "runtime" "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) -// TestMultiFormatLoading tests loading different config formats -func TestMultiFormatLoading(t *testing.T) { - tmpDir := t.TempDir() - - // Create test config in different formats - tomlConfig := ` -[server] -host = "toml-host" -port = 8080 - -[database] -url = "postgres://localhost/toml" -` - - jsonConfig := `{ - "server": { - "host": "json-host", - "port": 9090 - }, - "database": { - "url": "postgres://localhost/json" - } - }` - - yamlConfig := ` -server: - host: yaml-host - port: 7070 -database: - url: postgres://localhost/yaml -` - - // Write config files - tomlPath := filepath.Join(tmpDir, "config.toml") - jsonPath := filepath.Join(tmpDir, "config.json") - yamlPath := filepath.Join(tmpDir, "config.yaml") - - require.NoError(t, os.WriteFile(tomlPath, []byte(tomlConfig), 0644)) - require.NoError(t, os.WriteFile(jsonPath, []byte(jsonConfig), 0644)) - require.NoError(t, os.WriteFile(yamlPath, []byte(yamlConfig), 0644)) - - t.Run("AutoDetectFormats", func(t *testing.T) { - cfg := New() - cfg.Register("server.host", "") - cfg.Register("server.port", 0) - cfg.Register("database.url", "") - - // Test TOML - cfg.SetFileFormat("auto") - require.NoError(t, cfg.LoadFile(tomlPath)) - host, _ := cfg.Get("server.host") - assert.Equal(t, "toml-host", host) - - // Test JSON - require.NoError(t, cfg.LoadFile(jsonPath)) - host, _ = cfg.Get("server.host") - assert.Equal(t, "json-host", host) - port, _ := cfg.Get("server.port") - // JSON number should be preserved as json.Number but convertible - switch v := port.(type) { - case json.Number: - // Expected for raw value - assert.Equal(t, json.Number("9090"), v) - case int64: - // Expected after decode hook conversion - assert.Equal(t, int64(9090), v) - case float64: - // Alternative conversion - assert.Equal(t, float64(9090), v) - default: - t.Errorf("Unexpected type for port: %T", port) - } - - // Test YAML - require.NoError(t, cfg.LoadFile(yamlPath)) - host, _ = cfg.Get("server.host") - assert.Equal(t, "yaml-host", host) - }) - - t.Run("ExplicitFormat", func(t *testing.T) { - cfg := New() - cfg.Register("server.host", "") - - // Force JSON parsing on .conf file - confPath := filepath.Join(tmpDir, "config.conf") - require.NoError(t, os.WriteFile(confPath, []byte(jsonConfig), 0644)) - - cfg.SetFileFormat("json") - require.NoError(t, cfg.LoadFile(confPath)) - - host, _ := cfg.Get("server.host") - assert.Equal(t, "json-host", host) - }) - - t.Run("ContentDetection", func(t *testing.T) { - cfg := New() - cfg.Register("server.host", "") - - // Ambiguous extension - ambigPath := filepath.Join(tmpDir, "config.conf") - require.NoError(t, os.WriteFile(ambigPath, []byte(yamlConfig), 0644)) - - cfg.SetFileFormat("auto") - require.NoError(t, cfg.LoadFile(ambigPath)) - - host, _ := cfg.Get("server.host") - assert.Equal(t, "yaml-host", host) - }) -} - -// TestDynamicFormatSwitching tests runtime format changes -func TestDynamicFormatSwitching(t *testing.T) { - tmpDir := t.TempDir() - - // Create configs in different formats with same structure - configs := map[string]string{ - "toml": `value = "from-toml"`, - "json": `{"value": "from-json"}`, - "yaml": `value: from-yaml`, +func TestTOMLOnly(t *testing.T) { + for _, format := range []string{"json", "yaml", "other"} { + c := New() + require.Error(t, c.SetFileFormat(format)) + _, err := NewBuilder().WithTagName(format).Build() + require.Error(t, err) + var dst struct{ Value string } + require.Error(t, ScanMap(nil, &dst, format)) } - - cfg := New() - cfg.Register("value", "default") - - for format, content := range configs { - t.Run(format, func(t *testing.T) { - filePath := filepath.Join(tmpDir, "config."+format) - require.NoError(t, os.WriteFile(filePath, []byte(content), 0644)) - - // Set format and load - require.NoError(t, cfg.SetFileFormat(format)) - require.NoError(t, cfg.LoadFile(filePath)) - - val, _ := cfg.Get("value") - assert.Equal(t, "from-"+format, val) - }) + for _, tc := range []struct{ ext, content string }{ + {".toml", `value = "toml"`}, {".conf", `value = "toml"`}, {".config", `value = "toml"`}, + } { + c := New() + require.NoError(t, c.Register("value", "")) + path := filepath.Join(t.TempDir(), "config"+tc.ext) + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600)) + require.NoError(t, c.LoadFile(path)) + } + for _, tc := range []struct{ ext, content string }{ + {".json", `{"value":"json"}`}, {".yaml", "value: yaml"}, {".conf", `{"value":"json"}`}, + } { + c := New() + require.NoError(t, c.Register("value", "")) + path := filepath.Join(t.TempDir(), "config"+tc.ext) + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600)) + require.Error(t, c.LoadFile(path)) } } -// TestWatchFileFormatSwitch tests watching different file formats -func TestWatchFileFormatSwitch(t *testing.T) { - tmpDir := t.TempDir() - - tomlPath := filepath.Join(tmpDir, "config.toml") - jsonPath := filepath.Join(tmpDir, "config.json") - - require.NoError(t, os.WriteFile(tomlPath, []byte(`value = "toml-1"`), 0644)) - require.NoError(t, os.WriteFile(jsonPath, []byte(`{"value": "json-1"}`), 0644)) - - cfg := New() - cfg.Register("value", "default") - - // Configure fast polling for test - opts := WatchOptions{ - PollInterval: testPollInterval, // Fast polling for tests - Debounce: testDebounce, // Short debounce - MaxWatchers: 10, - } - - // Start watching TOML - cfg.SetFileFormat("auto") - require.NoError(t, cfg.LoadFile(tomlPath)) - cfg.AutoUpdateWithOptions(opts) - defer cfg.StopAutoUpdate() - - // Wait for watcher to start - require.Eventually(t, func() bool { - return cfg.IsWatching() - }, 4*testDebounce, 2*SpinWaitInterval) - - val, _ := cfg.Get("value") - assert.Equal(t, "toml-1", val) - - // Switch to JSON with format hint - require.NoError(t, cfg.WatchFile(jsonPath, "json")) - - // Wait for new watcher to start - require.Eventually(t, func() bool { - return cfg.IsWatching() - }, 4*testDebounce, 2*SpinWaitInterval) - - // Get watch channel AFTER switching files - changes := cfg.Watch() - - val, _ = cfg.Get("value") - assert.Equal(t, "json-1", val) - - // Update JSON file - require.NoError(t, os.WriteFile(jsonPath, []byte(`{"value": "json-2"}`), 0644)) - - // Wait for change notification - select { - case path := <-changes: - assert.Equal(t, "value", path) - // Wait a bit for value to be updated - require.Eventually(t, func() bool { - val, _ := cfg.Get("value") - return val == "json-2" - }, testEventuallyTimeout, 2*SpinWaitInterval) - case <-time.After(testWatchTimeout): - t.Error("Timeout waiting for JSON file change") - } - - // Update old TOML file - should NOT trigger notification - require.NoError(t, os.WriteFile(tomlPath, []byte(`value = "toml-2"`), 0644)) - - // Should not receive notification from old file - select { - case <-changes: - t.Error("Should not receive changes from old TOML file") - case <-time.After(testPollWindow): - // Expected - no change notification - } -} - -// TestSecurityOptions tests security features func TestSecurityOptions(t *testing.T) { tmpDir := t.TempDir() @@ -307,112 +116,3 @@ func waitForWatchingState(t *testing.T, cfg *Config, expected bool, msgAndArgs . return cfg.IsWatching() == expected }, testEventuallyTimeout, 2*SpinWaitInterval, msgAndArgs...) } - -// TestBuilderWithFormat tests Builder integration -func TestBuilderWithFormat(t *testing.T) { - tmpDir := t.TempDir() - jsonPath := filepath.Join(tmpDir, "config.json") - - jsonConfig := `{ - "server": { - "host": "builder-host", - "port": 8080 - } - }` - require.NoError(t, os.WriteFile(jsonPath, []byte(jsonConfig), 0644)) - - type Config struct { - Server struct { - Host string `json:"host" toml:"host"` - Port int `json:"port" toml:"port"` - } `json:"server" toml:"server"` - } - - defaults := &Config{} - defaults.Server.Host = "default-host" - defaults.Server.Port = 3000 - - cfg, err := NewBuilder(). - WithDefaults(defaults). - WithFile(jsonPath). - WithFileFormat("json"). - WithTagName("toml"). // Use toml tags for registration - WithSecurityOptions(SecurityOptions{ - PreventPathTraversal: true, - MaxFileSize: 1024 * 1024, // 1MB - }). - Build() - - require.NoError(t, err) - - // Check the value was loaded - host, exists := cfg.Get("server.host") - assert.True(t, exists, "server.host should exist") - assert.Equal(t, "builder-host", host) - - port, exists := cfg.Get("server.port") - assert.True(t, exists, "server.port should exist") - // Handle json.Number or converted int - switch v := port.(type) { - case json.Number: - p, _ := v.Int64() - assert.Equal(t, int64(8080), p) - case int64: - assert.Equal(t, int64(8080), v) - case float64: - assert.Equal(t, float64(8080), v) - default: - t.Errorf("Unexpected type for port: %T", port) - } -} - -// BenchmarkFormatParsing benchmarks different format parsing speeds -func BenchmarkFormatParsing(b *testing.B) { - tmpDir := b.TempDir() - - // Create test data - configs := map[string]string{ - "toml": ` -[server] -host = "localhost" -port = 8080 -[database] -url = "postgres://localhost/db" -[cache] -ttl = 300 -`, - "json": `{ - "server": {"host": "localhost", "port": 8080}, - "database": {"url": "postgres://localhost/db"}, - "cache": {"ttl": 300} - }`, - "yaml": ` -server: - host: localhost - port: 8080 -database: - url: postgres://localhost/db -cache: - ttl: 300 -`, - } - - for format, content := range configs { - b.Run(format, func(b *testing.B) { - path := filepath.Join(tmpDir, "bench."+format) - os.WriteFile(path, []byte(content), 0644) - - cfg := New() - cfg.Register("server.host", "") - cfg.Register("server.port", 0) - cfg.Register("database.url", "") - cfg.Register("cache.ttl", 0) - cfg.SetFileFormat(format) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - cfg.LoadFile(path) - } - }) - } -} diff --git a/error.go b/error.go index 600d97f..72388c8 100644 --- a/error.go +++ b/error.go @@ -52,4 +52,4 @@ var ( // allowing errors.Is() to work on both. func wrapError(base error, detail error) error { return errors.Join(base, detail) -} \ No newline at end of file +} diff --git a/example/main.go b/example/main.go index b3f7670..9e173db 100644 --- a/example/main.go +++ b/example/main.go @@ -64,7 +64,7 @@ func main() { log.Println(" (Set environment variable APP_SERVER_PORT=8888)") // Create a "target" struct. The builder will automatically populate this - // and keep it updated when using `AsStruct()` + // once; later `AsStruct()` calls return independent updated snapshots. target := &AppConfig{} // Use the builder to chain multiple configuration options @@ -199,4 +199,4 @@ func printCurrentState(cfg *AppConfig, title string) { fmt.Printf(" Server Log Level: %s\n", cfg.Server.LogLevel) fmt.Printf(" Feature Flags: %v\n", cfg.FeatureFlags) fmt.Println(" --------------------------------------------------") -} \ No newline at end of file +} diff --git a/file.go b/file.go new file mode 100644 index 0000000..e157a7c --- /dev/null +++ b/file.go @@ -0,0 +1,347 @@ +package config + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "time" + + "github.com/lixenwraith/toml" +) + +type fileSettings struct{ security SecurityOptions } +type fileSnapshot struct { + data []byte + info os.FileInfo + digest [32]byte +} + +func (c *Config) fileSettingsLocked() fileSettings { + var s fileSettings + if c.securityOpts != nil { + s.security = *c.securityOpts + } + return s +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r contextReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(p) +} + +// Read a regular file and validate the opened descriptor, not just the pathname. +func readConfigFile(ctx context.Context, path string, opts fileSettings) (*fileSnapshot, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if opts.security.PreventPathTraversal { + clean := filepath.Clean(path) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return nil, wrapError(ErrFileAccess, fmt.Errorf("path traversal in %q", path)) + } + } + if ext := strings.ToLower(filepath.Ext(path)); ext == ".json" || ext == ".yaml" || ext == ".yml" { + return nil, ErrFileFormat + } + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, ErrConfigNotFound + } + return nil, wrapError(ErrFileAccess, err) + } + if !info.Mode().IsRegular() { + return nil, wrapError(ErrFileAccess, fmt.Errorf("config must be a regular file")) + } + file, err := os.Open(path) + if err != nil { + return nil, wrapError(ErrFileAccess, err) + } + defer file.Close() + info, err = file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, wrapError(ErrFileAccess, fmt.Errorf("config must be a regular file")) + } + if opts.security.EnforceFileOwnership { + if err := checkFileOwner(info); err != nil { + return nil, err + } + } + limit := opts.security.MaxFileSize + if limit > 0 && info.Size() > limit { + return nil, wrapError(ErrFileAccess, fmt.Errorf("config exceeds maximum size %d", limit)) + } + var reader io.Reader = contextReader{ctx, file} + if limit > 0 && limit < int64(^uint64(0)>>1) { + reader = io.LimitReader(reader, limit+1) + } + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + if limit > 0 && int64(len(data)) > limit { + return nil, wrapError(ErrFileAccess, fmt.Errorf("config exceeds maximum size %d", limit)) + } + after, err := file.Stat() + if err != nil { + return nil, err + } + if info.Size() != after.Size() || !info.ModTime().Equal(after.ModTime()) { + return nil, fmt.Errorf("config changed during read") + } + if err := ctx.Err(); err != nil { + return nil, err + } + return &fileSnapshot{data, after, sha256.Sum256(data)}, nil +} + +func parseFile(snapshot *fileSnapshot) (map[string]any, []string, error) { + root, err := toml.NewParser(snapshot.data).Parse() + if err != nil { + return nil, nil, wrapError(ErrDecode, fmt.Errorf("failed to parse TOML: %w", err)) + } + var comments []string + lexer := toml.NewLexer(snapshot.data) + for { + token := lexer.NextToken() + if token.Type == toml.TokenEOF { + break + } + if token.Type == toml.TokenError { + return nil, nil, wrapError(ErrDecode, fmt.Errorf("%s", token.Literal)) + } + if token.Type == toml.TokenComment { + comments = append(comments, token.Literal) + } + } + return root, comments, nil +} + +func (c *Config) loadFile(path string) error { + c.mutex.RLock() + opts := c.fileSettingsLocked() + c.mutex.RUnlock() + snapshot, err := readConfigFile(context.Background(), path, opts) + if err != nil { + return err + } + root, comments, err := parseFile(snapshot) + if err != nil { + return err + } + c.mutex.Lock() + defer c.mutex.Unlock() + if err := c.applyFileLocked(root, comments, path); err != nil { + return err + } + if c.watcher != nil && c.watcher.filePath != path { + c.watcher.stop() + c.watcher = nil + } + return nil +} + +func (c *Config) applyFileLocked(root map[string]any, comments []string, path string, guard ...func() error) error { + values := make(map[string]any) + for registered := range c.items { + var current any = root + exists := true + for _, key := range strings.Split(registered, ".") { + m, ok := current.(map[string]any) + if !ok { + return wrapError(ErrTypeMismatch, fmt.Errorf("scalar parent of registered path %q", registered)) + } + current, exists = m[key] + if !exists { + break + } + } + if exists { + values[registered] = current + } + } + if err := c.replaceSourceLocked(SourceFile, values, guard...); err != nil { + return err + } + c.configFilePath, c.fileComments = path, comments + c.fileGeneration++ + return nil +} + +// Save writes registered effective values as TOML. Comments from the loaded file +// are retained as a preamble; their original positions and whitespace are not. +func (c *Config) Save(path string) error { return c.save(path, "") } +func (c *Config) SaveSource(path string, source Source) error { + if !validSource(source) { + return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source)) + } + return c.save(path, source) +} +func (c *Config) save(path string, source Source) error { + c.mutex.RLock() + nested := c.nestedLocked(source) + comments := append([]string(nil), c.fileComments...) + c.mutex.RUnlock() + data, err := marshalConfig(nested) + if err != nil { + return err + } + var preamble strings.Builder + for _, comment := range comments { + preamble.WriteByte('#') + preamble.WriteString(comment) + preamble.WriteByte('\n') + } + return atomicWriteFile(path, append([]byte(preamble.String()), data...)) +} + +func marshalConfig(value any) ([]byte, error) { + normalized, err := fileValue(reflect.ValueOf(value), 0) + if err != nil { + return nil, wrapError(ErrFileFormat, err) + } + data, err := toml.Marshal(normalized) + if err != nil { + return nil, wrapError(ErrFileFormat, err) + } + return data, nil +} + +func fileValue(v reflect.Value, depth int) (any, error) { + if depth > maxValueDepth { + return nil, fmt.Errorf("file value nesting exceeds %d", maxValueDepth) + } + for v.IsValid() && (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) { + if v.IsNil() { + return nil, nil + } + v = v.Elem() + depth++ + if depth > maxValueDepth { + return nil, fmt.Errorf("cyclic pointer") + } + } + if !v.IsValid() { + return nil, nil + } + switch v.Type() { + case durationType: + return time.Duration(v.Int()).String(), nil + case timeType: + return v.Interface().(time.Time).Format(time.RFC3339Nano), nil + case ipType: + ip := v.Interface().(net.IP) + if len(ip) == 0 { + return nil, nil + } + return ip.String(), nil + case ipNetType: + network := v.Interface().(net.IPNet) + return network.String(), nil + case urlType: + u := v.Interface().(url.URL) + return u.String(), nil + } + switch v.Kind() { + case reflect.Struct: + out := make(map[string]any) + for i := 0; i < v.NumField(); i++ { + f := v.Type().Field(i) + if !f.IsExported() || fieldKey(f) == "-" { + continue + } + value, err := fileValue(v.Field(i), depth+1) + if err != nil { + return nil, err + } + key := fieldKey(f) + if _, exists := out[key]; exists { + return nil, fmt.Errorf("duplicate TOML field %q", key) + } + out[key] = value + } + return out, nil + case reflect.Map: + if v.Type().Key().Kind() != reflect.String { + return nil, fmt.Errorf("map keys must be strings") + } + out := make(map[string]any, v.Len()) + it := v.MapRange() + for it.Next() { + value, err := fileValue(it.Value(), depth+1) + if err != nil { + return nil, err + } + out[it.Key().String()] = value + } + return out, nil + case reflect.Array, reflect.Slice: + out := make([]any, v.Len()) + for i := range out { + value, err := fileValue(v.Index(i), depth+1) + if err != nil { + return nil, err + } + out[i] = value + } + return out, nil + default: + return v.Interface(), nil + } +} + +func atomicWriteFile(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, DirPermissions); err != nil { + return wrapError(ErrFileAccess, err) + } + mode := os.FileMode(0600) + if info, err := os.Stat(path); err == nil { + if !info.Mode().IsRegular() { + return wrapError(ErrFileAccess, fmt.Errorf("destination must be a regular file")) + } + mode = info.Mode().Perm() + } else if !errors.Is(err, os.ErrNotExist) { + return wrapError(ErrFileAccess, err) + } + f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") + if err != nil { + return wrapError(ErrFileAccess, err) + } + defer os.Remove(f.Name()) + defer f.Close() + if _, err = f.Write(data); err == nil { + err = f.Chmod(mode) + } + if err == nil { + err = f.Sync() + } + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Rename(f.Name(), path) + } + if err != nil { + return wrapError(ErrFileAccess, err) + } + return nil +} diff --git a/go.mod b/go.mod index d4aebb9..1cc7688 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,14 @@ module github.com/lixenwraith/config -go 1.26.0 +go 1.27.1 require ( - github.com/BurntSushi/toml v1.6.0 - github.com/go-viper/mapstructure/v2 v2.5.0 + github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 github.com/stretchr/testify v1.10.0 - gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c783fec..47ac435 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,7 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= -github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 h1:9qZU0iBIsqintDh39vieio0ygLiRrPmCEo6Ra45EQyY= +github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= diff --git a/hardening_test.go b/hardening_test.go new file mode 100644 index 0000000..3f41fe5 --- /dev/null +++ b/hardening_test.go @@ -0,0 +1,303 @@ +package config + +import ( + "errors" + "math" + "net" + "net/url" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +func TestCheckedConversionsAndRollback(t *testing.T) { + type limits struct { + U uint64 `toml:"u"` + I int64 `toml:"i"` + B uint8 `toml:"b"` + F float64 `toml:"f"` + } + initial := limits{1, 2, 3, 4} + for _, input := range []map[string]any{ + {"u": "18446744073709551616"}, {"u": int64(-1)}, {"i": uint64(math.MaxUint64)}, + {"i": float64(0x1p63)}, {"u": float64(0x1p64)}, {"b": 256}, {"i": 1.5}, + {"f": uint64(1<<53) + 1}, {"f": math.NaN()}, {"f": "Inf"}, + } { + got := initial + if err := ScanMap(input, &got); err == nil || got != initial { + t.Fatalf("accepted or published invalid %#v: %+v (%v)", input, got, err) + } + } + var got limits + if err := ScanMap(map[string]any{"u": "18446744073709551615", "i": int64(math.MinInt64), "b": 255}, &got); err != nil || got.U != math.MaxUint64 || got.I != math.MinInt64 || got.B != 255 { + t.Fatalf("boundaries: %+v %v", got, err) + } + var array struct { + Values [2]uint16 `toml:"values"` + } + if err := ScanMap(map[string]any{"values": "1,65535"}, &array); err != nil || array.Values != [2]uint16{1, 65535} { + t.Fatalf("array: %+v %v", array, err) + } +} + +func TestOwnedValuesAndSnapshots(t *testing.T) { + type settings struct { + Values map[string][]int `toml:"values"` + Port uint16 `toml:"port"` + } + target := settings{Values: map[string][]int{"a": {1}}, Port: 80} + c, err := NewBuilder().WithTarget(&target).WithArgs(nil).Build() + if err != nil { + t.Fatal(err) + } + target.Values["a"][0] = 99 + first, err := c.AsStruct() + if err != nil { + t.Fatal(err) + } + if first.(*settings).Values["a"][0] != 1 { + t.Fatal("target defaults alias internal values") + } + first.(*settings).Values["a"][0] = 88 + raw, _ := c.Get("values") + raw.(map[string][]int)["a"][0] = 77 + source := map[string][]int{"a": {2}} + if err := c.Set("values", source); err != nil { + t.Fatal(err) + } + source["a"][0] = 66 + second, err := c.AsStruct() + if err != nil { + t.Fatal(err) + } + if second.(*settings).Values["a"][0] != 2 || first.(*settings).Values["a"][0] != 88 { + t.Fatal("snapshot changed or input leaked") + } + clone := c.Clone() + v, _ := clone.Get("values") + v.(map[string][]int)["a"][0] = 44 + if err := clone.Set("values", map[string][]int{"a": {3}}); err != nil { + t.Fatal(err) + } + third, _ := c.AsStruct() + if third.(*settings).Values["a"][0] != 2 { + t.Fatal("clone aliases original") + } + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + for range 40 { + s, err := c.AsStruct() + if err != nil { + t.Error(err) + return + } + s.(*settings).Values["a"][0]++ + } + }) + } + wg.Wait() +} + +func TestAtomicValuesAndFileRoundTrip(t *testing.T) { + type settings struct { + IP net.IP `toml:"ip"` + Network net.IPNet `toml:"network"` + URL url.URL `toml:"url"` + Time *time.Time `toml:"time"` + Delay time.Duration `toml:"delay"` + } + _, network, _ := net.ParseCIDR("10.2.0.0/16") + endpoint, _ := url.Parse("https://user:pass@example.com/path?q=x") + stamp := time.Date(2026, 9, 28, 12, 0, 0, 123, time.UTC) + initial := settings{net.ParseIP("::1"), *network, *endpoint, &stamp, 1500 * time.Millisecond} + c := New() + if err := c.RegisterStruct("", initial); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "config.toml") + if err := c.Save(path); err != nil { + t.Fatal(err) + } + next := New() + if err := next.RegisterStruct("", settings{}); err != nil { + t.Fatal(err) + } + if err := next.LoadFile(path); err != nil { + t.Fatal(err) + } + var got settings + if err := next.Scan(&got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, initial) { + t.Fatalf("atomic roundtrip: %+v != %+v", got, initial) + } + for _, bad := range []any{net.IP{1}, net.IPNet{IP: net.IP{1}, Mask: net.IPMask{255}}, math.NaN(), make(chan int)} { + if err := c.Register("bad", bad); err == nil { + t.Fatalf("accepted %T", bad) + } + } + cycle := map[string]any{} + cycle["self"] = cycle + if err := c.Register("cycle", cycle); err == nil { + t.Fatal("accepted cycle") + } + var p any + p = &p + if err := decodeConfig(p, new(int)); err == nil { + t.Fatal("accepted cyclic pointer") + } +} + +func TestSourceReplacementIsTransactional(t *testing.T) { + c := New() + _ = c.Register("port", uint16(80)) + _ = c.Register("name", "default") + if err := c.LoadCLI([]string{"--port=8080", "--name=old"}); err != nil { + t.Fatal(err) + } + if err := c.LoadCLI([]string{"--name=new", "--port=65536"}); err == nil { + t.Fatal("accepted overflow") + } + if v, _ := c.Get("name"); v != "old" { + t.Fatal("partially published CLI") + } + if err := c.LoadCLI([]string{"--port=90", "--", "--name=ignored"}); err != nil { + t.Fatal(err) + } + if v, _ := c.Get("name"); v != "default" { + t.Fatal("stale CLI value") + } + t.Setenv("AUDIT_PORT", "100") + if err := c.LoadEnv("AUDIT_"); err != nil { + t.Fatal(err) + } + if err := os.Unsetenv("AUDIT_PORT"); err != nil { + t.Fatal(err) + } + if err := c.LoadEnv("AUDIT_"); err != nil { + t.Fatal(err) + } + if _, ok := c.GetSource("port", SourceEnv); ok { + t.Fatal("removed environment variable retained") + } + path := filepath.Join(t.TempDir(), "c.toml") + _ = os.WriteFile(path, []byte("port=100\nname=\"valid\""), 0600) + if err := c.LoadFile(path); err != nil { + t.Fatal(err) + } + _ = os.WriteFile(path, []byte("port=65536\nname=\"invalid\""), 0600) + if err := c.LoadFile(path); err == nil { + t.Fatal("accepted invalid file") + } + if v, _ := c.GetSource("name", SourceFile); v != "valid" { + t.Fatal("partially published file") + } + if err := c.SetSource(Source("invalid"), "name", "x"); err == nil { + t.Fatal("accepted invalid source") + } + if err := c.Register("name.child", "x"); err == nil { + t.Fatal("accepted overlapping path") + } +} + +func TestSaveCommentsPermissionsAndFailure(t *testing.T) { + c := New() + _ = c.Register("value", uint64(0)) + _ = c.Register("text", "") + path := filepath.Join(t.TempDir(), "c.toml") + original := "# heading\nvalue=7 # inline\ntext=\"hash # inside\"\n" + _ = os.WriteFile(path, []byte(original), 0600) + if err := c.LoadFile(path); err != nil { + t.Fatal(err) + } + if err := c.Save(path); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(path) + info, _ := os.Stat(path) + if strings.Count(string(data), "# heading") != 1 || strings.Count(string(data), "# inline") != 1 || !strings.Contains(string(data), `hash # inside`) || info.Mode().Perm() != 0600 { + t.Fatalf("save: %s mode=%v", data, info.Mode()) + } + before := string(data) + if err := c.Set("value", uint64(math.MaxUint64)); err != nil { + t.Fatal(err) + } + if err := c.Save(path); !errors.Is(err, ErrFileFormat) { + t.Fatalf("expected range error, got %v", err) + } + after, _ := os.ReadFile(path) + if string(after) != before { + t.Fatal("failed save changed destination") + } +} + +func FuzzConfigNumericConversions(f *testing.F) { + for _, v := range []uint64{0, 255, 256, 1 << 63, math.MaxUint64} { + f.Add(v) + } + f.Fuzz(func(t *testing.T, n uint64) { + var u uint64 + if err := decodeConfig(n, &u); err != nil || u != n { + t.Fatalf("uint64 loss %d %v", u, err) + } + var b uint8 + err := decodeConfig(n, &b) + if (err == nil) != (n <= 255) || err == nil && uint64(b) != n { + t.Fatalf("narrowing %d %v", b, err) + } + var i int64 + err = decodeConfig(n, &i) + if (err == nil) != (n <= math.MaxInt64) || err == nil && uint64(i) != n { + t.Fatalf("signed %d %v", i, err) + } + }) +} + +func TestRegistrationAndRequiredContracts(t *testing.T) { + type settings struct { + Port uint16 `toml:"port" required:"true"` + private string + } + target := settings{Port: 80} + if _, err := NewBuilder().WithTarget(&target).WithArgs(nil).Build(); !errors.Is(err, ErrValidation) { + t.Fatalf("required field ignored: %v", err) + } + c, err := NewBuilder().WithTarget(&target).WithArgs([]string{"--port=80"}).Build() + if err != nil { + t.Fatal(err) + } + if _, err := c.AsStruct(); err != nil { + t.Fatal(err) + } + if err := c.SetPrecedence(SourceDefault, SourceCLI); err != nil { + t.Fatal(err) + } + if err := c.Validate(); err == nil { + t.Fatal("inactive override satisfied required field") + } + type wrong struct { + Port uint64 `toml:"port"` + } + if _, err := NewBuilder().WithTarget(&target).WithDefaults(wrong{}).Build(); err == nil { + t.Fatal("mismatched target schema accepted") + } + type duplicate struct { + A int `toml:"same"` + B int `toml:"same"` + } + empty := New() + if err := empty.RegisterStruct("", duplicate{}); err == nil || len(empty.GetRegisteredPaths()) != 0 { + t.Fatal("partial or duplicate schema accepted") + } + for _, n := range []float64{math.NaN(), math.Inf(1)} { + if Positive(n) == nil || NonNegative(n) == nil || Range[float64](0, 10)(n) == nil { + t.Fatal("non-finite validator input accepted") + } + } +} diff --git a/helper.go b/helper.go index 81366d5..a81f86f 100644 --- a/helper.go +++ b/helper.go @@ -74,6 +74,15 @@ func isValidKeySegment(s string) bool { return false // Segments themselves cannot contain dots } + numeric := strings.TrimLeft(s, "-") != "" + for _, r := range strings.TrimLeft(s, "-") { + if r < '0' || r > '9' { + numeric = false + } + } + if numeric { + return false + } for _, r := range s { isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') isDigit := r >= '0' && r <= '9' @@ -85,4 +94,4 @@ func isValidKeySegment(s string) bool { } } return true -} \ No newline at end of file +} diff --git a/loader.go b/loader.go index 4e1494a..d831472 100644 --- a/loader.go +++ b/loader.go @@ -2,19 +2,13 @@ package config import ( - "bytes" - "encoding/json" "errors" "fmt" - "io" + "maps" "os" - "path/filepath" - "runtime" + "reflect" + "slices" "strings" - "syscall" - - "github.com/BurntSushi/toml" - "gopkg.in/yaml.v3" ) // Source represents a configuration source, used to define load precedence @@ -51,7 +45,8 @@ type LoadOptions struct { // EnvWhitelist limits which paths are checked for env vars (nil = all) EnvWhitelist map[string]bool - // SkipValidation skips path validation during load + // Deprecated: SkipValidation is retained for source compatibility and ignored. + // Path and value checks are always enforced. SkipValidation bool } @@ -62,13 +57,22 @@ func DefaultLoadOptions() LoadOptions { } } +func cloneLoadOptions(opts LoadOptions) LoadOptions { + opts.Sources = slices.Clone(opts.Sources) + if len(opts.Sources) == 0 { + opts.Sources = DefaultLoadOptions().Sources + } + opts.EnvWhitelist = maps.Clone(opts.EnvWhitelist) + return opts +} + // loadWithOptions loads configuration from multiple sources with custom options func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error { - c.mutex.Lock() - c.options = opts - c.mutex.Unlock() + opts = cloneLoadOptions(opts) + c.SetLoadOptions(opts) var loadErrors []error + var missingFile error // Process each source according to precedence (in reverse order for proper layering) for i := len(opts.Sources) - 1; i >= 0; i-- { @@ -83,7 +87,7 @@ func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOption if filePath != "" { if err := c.loadFile(filePath); err != nil { if errors.Is(err, ErrConfigNotFound) { - loadErrors = append(loadErrors, err) + missingFile = err } else { return wrapError(ErrFileAccess, err) // Fatal error } @@ -96,20 +100,23 @@ func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOption } case SourceCLI: - if len(args) > 0 { - if err := c.loadCLI(args); err != nil { - loadErrors = append(loadErrors, wrapError(ErrCLIParse, err)) - } + if err := c.loadCLI(args); err != nil { + loadErrors = append(loadErrors, wrapError(ErrCLIParse, err)) } } } - return errors.Join(loadErrors...) + if len(loadErrors) != 0 { + return errors.Join(loadErrors...) + } + return missingFile } // LoadEnv loads configuration values from environment variables func (c *Config) LoadEnv(prefix string) error { - opts := c.options + c.mutex.RLock() + opts := cloneLoadOptions(c.options) + c.mutex.RUnlock() opts.EnvPrefix = prefix return c.loadEnv(opts) } @@ -130,115 +137,23 @@ func (c *Config) LoadFile(filePath string) error { return nil } -// Save writes the current configuration to a TOML file atomically. -// Only registered paths are saved. -func (c *Config) Save(path string) error { - c.mutex.RLock() - - nestedData := make(map[string]any) - for itemPath, item := range c.items { - setNestedValue(nestedData, itemPath, item.currentValue) - } - - c.mutex.RUnlock() - - // Marshal using BurntSushi/toml - var buf bytes.Buffer - encoder := toml.NewEncoder(&buf) - if err := encoder.Encode(nestedData); err != nil { - return wrapError(ErrFileFormat, fmt.Errorf("failed to marshal config data to TOML: %w", err)) - } - tomlData := buf.Bytes() - - // Atomic write logic - dir := filepath.Dir(path) - // Ensure the directory exists - if err := os.MkdirAll(dir, DirPermissions); err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to create config directory '%s': %w", dir, err)) - } - - // Create a temporary file in the same directory - tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") - if err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to create temporary config file in '%s': %w", dir, err)) - } - - tempFilePath := tempFile.Name() - removed := false - defer func() { - if !removed { - os.Remove(tempFilePath) - } - }() - - // Write data to the temporary file - if _, err := tempFile.Write(tomlData); err != nil { - tempFile.Close() - return wrapError(ErrFileAccess, fmt.Errorf("failed to write temp config file '%s': %w", tempFilePath, err)) - } - - // Sync data to disk - if err := tempFile.Sync(); err != nil { - tempFile.Close() - return wrapError(ErrFileAccess, fmt.Errorf("failed to sync temp config file '%s': %w", tempFilePath, err)) - } - - // Close the temporary file - if err := tempFile.Close(); err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to close temp config file '%s': %w", tempFilePath, err)) - } - - // Set permissions on the temporary file - if err := os.Chmod(tempFilePath, FilePermissions); err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to set permissions on temporary config file '%s': %w", tempFilePath, err)) - } - - // Atomically replace the original file - if err := os.Rename(tempFilePath, path); err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to rename temp file '%s' to '%s': %w", tempFilePath, path, err)) - } - removed = true - - return nil -} - -// SaveSource writes values from a specific source to a TOML file -func (c *Config) SaveSource(path string, source Source) error { - c.mutex.RLock() - - nestedData := make(map[string]any) - for itemPath, item := range c.items { - if val, exists := item.values[source]; exists { - setNestedValue(nestedData, itemPath, val) - } - } - - c.mutex.RUnlock() - - // Marshal using BurntSushi/toml - var buf bytes.Buffer - encoder := toml.NewEncoder(&buf) - if err := encoder.Encode(nestedData); err != nil { - return wrapError(ErrFileFormat, fmt.Errorf("failed to marshal %s source data to TOML: %w", source, err)) - } - - return atomicWriteFile(path, buf.Bytes()) -} - // DiscoverEnv finds all environment variables matching registered paths // and returns a map of path -> env var name for found variables func (c *Config) DiscoverEnv(prefix string) map[string]string { + c.mutex.RLock() transform := c.options.EnvTransform + paths := make([]string, 0, len(c.items)) + for path := range c.items { + paths = append(paths, path) + } + c.mutex.RUnlock() if transform == nil { transform = defaultEnvTransform(prefix) } - c.mutex.RLock() - defer c.mutex.RUnlock() - discovered := make(map[string]string) - for path := range c.items { + for _, path := range paths { envVar := transform(path) if _, exists := os.LookupEnv(envVar); exists { discovered[path] = envVar @@ -251,278 +166,109 @@ func (c *Config) DiscoverEnv(prefix string) map[string]string { // ExportEnv exports the current configuration as environment variables // Only exports paths that have non-default values func (c *Config) ExportEnv(prefix string) map[string]string { + c.mutex.RLock() transform := c.options.EnvTransform + changed := make(map[string]any) + for path, item := range c.items { + if !reflect.DeepEqual(item.currentValue, item.defaultValue) { + changed[path] = item.currentValue + } + } + c.mutex.RUnlock() if transform == nil { transform = defaultEnvTransform(prefix) } - c.mutex.RLock() - defer c.mutex.RUnlock() - exports := make(map[string]string) - for path, item := range c.items { - // Only export if value differs from default - if item.currentValue != item.defaultValue { - envVar := transform(path) - exports[envVar] = fmt.Sprintf("%v", item.currentValue) - } + for path, value := range changed { + exports[transform(path)] = fmt.Sprintf("%v", value) } return exports } -// loadFile reads and parses a TOML configuration file -func (c *Config) loadFile(path string) error { - // Security: Path traversal check - if c.securityOpts != nil && c.securityOpts.PreventPathTraversal { - // Clean the path and check for traversal attempts - cleanPath := filepath.Clean(path) - - // Check if cleaned path tries to go outside current directory - if strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || cleanPath == ".." { - return wrapError(ErrFileAccess, fmt.Errorf("potential path traversal detected in config path: %s", path)) - } - - // Also check for absolute paths that might escape jail - if filepath.IsAbs(cleanPath) && filepath.IsAbs(path) { - // Absolute paths are OK if that's what was provided - } else if filepath.IsAbs(cleanPath) && !filepath.IsAbs(path) { - // Relative path became absolute after cleaning - suspicious - return wrapError(ErrFileAccess, fmt.Errorf("potential path traversal detected in config path: %s", path)) - } - } - - // Read file with size limit - fileInfo, err := os.Stat(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return ErrConfigNotFound - } - return wrapError(ErrFileAccess, fmt.Errorf("failed to stat config file '%s': %w", path, err)) - } - - // Security: File size check - if c.securityOpts != nil && c.securityOpts.MaxFileSize > 0 { - if fileInfo.Size() > c.securityOpts.MaxFileSize { - return wrapError(ErrFileAccess, fmt.Errorf("config file '%s' exceeds maximum size %d bytes", path, c.securityOpts.MaxFileSize)) - } - } - - // Security: File ownership check (Unix only) - if c.securityOpts != nil && c.securityOpts.EnforceFileOwnership && runtime.GOOS != "windows" { - if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok { - if stat.Uid != uint32(os.Geteuid()) { - return wrapError(ErrFileAccess, fmt.Errorf("config file '%s' is not owned by current user (file UID: %d, process UID: %d)", - path, stat.Uid, os.Geteuid())) - } - } - } - - // 1. Read and parse file data - file, err := os.Open(path) - if err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to open config file '%s': %w", path, err)) - } - defer file.Close() - - // Use LimitedReader for additional safety - var reader io.Reader = file - if c.securityOpts != nil && c.securityOpts.MaxFileSize > 0 { - reader = io.LimitReader(file, c.securityOpts.MaxFileSize) - } - - fileData, err := io.ReadAll(reader) - if err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to read config file '%s': %w", path, err)) - } - - // Determine format - format := c.fileFormat - if format == "" || format == "auto" { - // Try extension first - format = detectFileFormat(path) - if format == "" { - // Fall back to content detection - format = detectFormatFromContent(fileData) - if format == "" { - // Last resort: use tagName as hint - format = c.tagName - } - } - } - - // Parse based on detected/specified format - fileConfig := make(map[string]any) - switch format { - case FormatTOML: - if err := toml.Unmarshal(fileData, &fileConfig); err != nil { - return wrapError(ErrDecode, fmt.Errorf("failed to parse TOML config file '%s': %w", path, err)) - } - case FormatJSON: - decoder := json.NewDecoder(bytes.NewReader(fileData)) - decoder.UseNumber() // Preserve number precision - if err := decoder.Decode(&fileConfig); err != nil { - return wrapError(ErrDecode, fmt.Errorf("failed to parse JSON config file '%s': %w", path, err)) - } - case FormatYAML: - if err := yaml.Unmarshal(fileData, &fileConfig); err != nil { - return wrapError(ErrDecode, fmt.Errorf("failed to parse YAML config file '%s': %w", path, err)) - } - default: - return wrapError(ErrFileFormat, fmt.Errorf("unable to determine config format for file '%s'", path)) - } - - // 2. Prepare New State (Read-Lock Only) - newFileData := make(map[string]any) - - // Briefly acquire a read-lock to safely get the list of registered paths. - c.mutex.RLock() - registeredPaths := make(map[string]bool, len(c.items)) - for p := range c.items { - registeredPaths[p] = true - } - c.mutex.RUnlock() - - // Define a recursive function to populate newFileData. This runs without any lock. - var apply func(prefix string, data map[string]any) - apply = func(prefix string, data map[string]any) { - for key, value := range data { - fullPath := key - if prefix != "" { - fullPath = prefix + "." + key - } - if registeredPaths[fullPath] { - newFileData[fullPath] = value - } else if subMap, isMap := value.(map[string]any); isMap { - apply(fullPath, subMap) - } - } - } - apply("", fileConfig) - - // 3. Atomically Update Config (Write-Lock) - c.mutex.Lock() - defer c.mutex.Unlock() - - c.configFilePath = path - c.fileData = newFileData - - // Apply the new state to the main config items. - for path, item := range c.items { - if value, exists := newFileData[path]; exists { - if item.values == nil { - item.values = make(map[Source]any) - } - item.values[SourceFile] = value - } else { - // Key was not in the new file, so remove its old file-sourced value. - delete(item.values, SourceFile) - } - // Recompute the current value based on new source precedence. - item.currentValue = c.computeValue(item) - c.items[path] = item - } - - c.invalidateCache() - return nil -} - -// loadEnv loads configuration from environment variables +// loadEnv replaces the complete environment source, including removed variables. func (c *Config) loadEnv(opts LoadOptions) error { transform := opts.EnvTransform if transform == nil { transform = defaultEnvTransform(opts.EnvPrefix) } - - // 1. Prepare data (Read-Lock to get paths) c.mutex.RLock() paths := make([]string, 0, len(c.items)) - for p := range c.items { - paths = append(paths, p) + for path := range c.items { + paths = append(paths, path) } c.mutex.RUnlock() - - // 2. Process env vars (No Lock) - foundEnvVars := make(map[string]string) + values := make(map[string]any) for _, path := range paths { if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] { continue } - - envVar := transform(path) - if value, exists := os.LookupEnv(envVar); exists { - if len(value) > MaxValueSize { - return ErrValueSize - } - foundEnvVars[path] = value + if value, ok := os.LookupEnv(transform(path)); ok { + values[path] = value } } - - // If no relevant env vars were found, we are done. - if len(foundEnvVars) == 0 { - return nil - } - - // -- 3. Atomically update config (Write-Lock) c.mutex.Lock() defer c.mutex.Unlock() - - c.envData = make(map[string]any, len(foundEnvVars)) - - for path, value := range foundEnvVars { - // Store raw string value - mapstructure will handle conversion later. - if item, exists := c.items[path]; exists { - if item.values == nil { - item.values = make(map[Source]any) - } - item.values[SourceEnv] = value // Store as string - item.currentValue = c.computeValue(item) - c.items[path] = item - c.envData[path] = value - } - } - - c.invalidateCache() - return nil + return c.replaceSourceLocked(SourceEnv, values) } -// loadCLI loads configuration from command-line arguments func (c *Config) loadCLI(args []string) error { - // -- 1. Prepare data (No Lock) - parsedCLI, err := parseArgs(args) + parsed, err := parseArgs(args) if err != nil { - return err // Already wrapped with error category in parseArgs + return err } - - flattenedCLI := flattenMap(parsedCLI, "") - if len(flattenedCLI) == 0 { - return nil // No CLI args to process. - } - - // 2. Atomically update config (Write-Lock) c.mutex.Lock() defer c.mutex.Unlock() + return c.replaceSourceLocked(SourceCLI, flattenMap(parsed, "")) +} - c.cliData = flattenedCLI - - // Reset per load; reloads must not accumulate duplicates - c.unknownCLIKeys = c.unknownCLIKeys[:0] - - for path, value := range flattenedCLI { - if item, exists := c.items[path]; exists { - if item.values == nil { - item.values = make(map[Source]any) +// Validate the entire replacement before any mutation. Caller holds c.mutex. +func (c *Config) replaceSourceLocked(source Source, values map[string]any, guard ...func() error) error { + accepted := make(map[string]any, len(values)) + var unknown []string + for path, value := range values { + item, ok := c.items[path] + if !ok { + if source == SourceCLI { + unknown = append(unknown, path) } - item.values[SourceCLI] = value - item.currentValue = c.computeValue(item) - c.items[path] = item - } else { - // Record CLI keys that match no registered path - c.unknownCLIKeys = append(c.unknownCLIKeys, path) + continue + } + if err := validateValue(item, value); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + owned, err := copyValue(value) + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + accepted[path] = owned + } + if len(guard) > 0 { + if err := guard[0](); err != nil { + return err } } - + for path, item := range c.items { + if value, ok := accepted[path]; ok { + item.values[source] = value + } else { + delete(item.values, source) + } + item.currentValue = c.computeValue(item) + c.items[path] = item + } + switch source { + case SourceFile: + c.fileData = accepted + case SourceEnv: + c.envData = accepted + case SourceCLI: + c.cliData = accepted + slices.Sort(unknown) + c.unknownCLIKeys = unknown + } c.invalidateCache() return nil } @@ -540,7 +286,7 @@ func defaultEnvTransform(prefix string) EnvTransformFunc { } // parseValue attempts to parse a string into appropriate types -// Only basic parse, complex parsing is deferred to mapstructure's decode hooks +// Only basic parse, complex parsing is deferred to typed decoding func parseValue(s string) any { if s == "true" { return true @@ -554,50 +300,10 @@ func parseValue(s string) any { return s[1 : len(s)-1] } - // Return as string - mapstructure will convert as needed + // Return as string - typed decoding converts as needed return s } -// atomicWriteFile performs atomic file write -func atomicWriteFile(path string, data []byte) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to create directory '%s': %w", dir, err)) - } - - tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") - if err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to create temporary file: %w", err)) - } - - tempPath := tempFile.Name() - defer os.Remove(tempPath) // Clean up on any error - - if _, err := tempFile.Write(data); err != nil { - tempFile.Close() - return wrapError(ErrFileAccess, fmt.Errorf("failed to write temporary file: %w", err)) - } - - if err := tempFile.Sync(); err != nil { - tempFile.Close() - return wrapError(ErrFileAccess, fmt.Errorf("failed to sync temporary file: %w", err)) - } - - if err := tempFile.Close(); err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to close temporary file: %w", err)) - } - - if err := os.Chmod(tempPath, 0644); err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to set permissions: %w", err)) - } - - if err := os.Rename(tempPath, path); err != nil { - return wrapError(ErrFileAccess, fmt.Errorf("failed to rename temporary file: %w", err)) - } - - return nil -} - // parseArgs processes command-line arguments into a nested map structure. func parseArgs(args []string) (map[string]any, error) { result := make(map[string]any) @@ -612,9 +318,8 @@ func parseArgs(args []string) (map[string]any, error) { argContent := strings.TrimPrefix(arg, "--") if argContent == "" { - // Skip "--" argument if used as a separator - i++ - continue + // Arguments after the terminator are positional. + break } var keyPath string @@ -654,49 +359,11 @@ func parseArgs(args []string) (map[string]any, error) { } // Always store as a string. Let Scan handle final type conversion. + if len(valueStr) > MaxValueSize { + return nil, ErrValueSize + } setNestedValue(result, keyPath, valueStr) } return result, nil } - -// detectFileFormat determines format from file extension -func detectFileFormat(path string) string { - ext := strings.ToLower(filepath.Ext(path)) - switch ext { - case ".toml", ".tml": - return FormatTOML - case ".json": - return FormatJSON - case ".yaml", ".yml": - return FormatYAML - case ".conf", ".config": - // Try to detect from content - return "" - default: - return "" - } -} - -// detectFormatFromContent attempts to detect format by parsing -func detectFormatFromContent(data []byte) string { - // Try JSON first (strict format) - var jsonTest any - if err := json.Unmarshal(data, &jsonTest); err == nil { - return FormatJSON - } - - // Try YAML (superset of JSON, so check after JSON) - var yamlTest any - if err := yaml.Unmarshal(data, &yamlTest); err == nil { - return FormatYAML - } - - // Try TOML last - var tomlTest any - if err := toml.Unmarshal(data, &tomlTest); err == nil { - return FormatTOML - } - - return "" -} diff --git a/loader_test.go b/loader_test.go index df73f87..9c1b804 100644 --- a/loader_test.go +++ b/loader_test.go @@ -431,4 +431,4 @@ func splitEnvVar(env string) []string { } } return []string{env} -} \ No newline at end of file +} diff --git a/ownership_other.go b/ownership_other.go new file mode 100644 index 0000000..51ba210 --- /dev/null +++ b/ownership_other.go @@ -0,0 +1,12 @@ +//go:build !unix + +package config + +import ( + "fmt" + "os" +) + +func checkFileOwner(os.FileInfo) error { + return wrapError(ErrFileAccess, fmt.Errorf("file ownership checks are unsupported on this platform")) +} diff --git a/ownership_test.go b/ownership_test.go new file mode 100644 index 0000000..88ed79a --- /dev/null +++ b/ownership_test.go @@ -0,0 +1,34 @@ +package config + +import "testing" + +func TestOptionsAreOwned(t *testing.T) { + opts := DefaultLoadOptions() + opts.EnvWhitelist = map[string]bool{"value": true} + c := NewWithOptions(opts) + opts.Sources[0] = SourceFile + opts.EnvWhitelist["value"] = false + if c.GetPrecedence()[0] != SourceCLI || !c.options.EnvWhitelist["value"] { + t.Fatal("options retained caller-owned containers") + } + c.SetLoadOptions(LoadOptions{}) + if err := c.Register("value", 0); err != nil { + t.Fatal(err) + } + if err := c.Set("value", 1); err != nil { + t.Fatal(err) + } +} + +func TestExportEnvContainerValues(t *testing.T) { + c := New() + if err := c.Register("items", []string{"old"}); err != nil { + t.Fatal(err) + } + if err := c.Set("items", []string{"new"}); err != nil { + t.Fatal(err) + } + if got := c.ExportEnv("APP_")["APP_ITEMS"]; got == "" { + t.Fatal("changed slice was not exported") + } +} diff --git a/ownership_unix.go b/ownership_unix.go new file mode 100644 index 0000000..13efb50 --- /dev/null +++ b/ownership_unix.go @@ -0,0 +1,17 @@ +//go:build unix + +package config + +import ( + "fmt" + "os" + "syscall" +) + +func checkFileOwner(info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Uid != uint32(os.Geteuid()) { + return wrapError(ErrFileAccess, fmt.Errorf("config file is not owned by current user")) + } + return nil +} diff --git a/register.go b/register.go index 1425e20..5725e8c 100644 --- a/register.go +++ b/register.go @@ -13,30 +13,71 @@ import ( // Each segment of the path must be a valid TOML key identifier // defaultValue is the value returned by Get if no specific value has been set func (c *Config) Register(path string, defaultValue any) error { + if err := validatePath(path); err != nil { + return err + } + owned, err := copyValue(defaultValue) + if err != nil { + return err + } + c.mutex.Lock() + defer c.mutex.Unlock() + return c.registerLocked(path, owned) +} + +func validatePath(path string) error { if path == "" { return wrapError(ErrInvalidPath, fmt.Errorf("registration path cannot be empty")) } - - // Validate path segments - segments := strings.Split(path, ".") - for _, segment := range segments { - if !isValidKeySegment(segment) { - return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q in path %q", segment, path)) + parts := strings.Split(path, ".") + if len(parts) > maxValueDepth { + return wrapError(ErrInvalidPath, fmt.Errorf("path nesting exceeds %d", maxValueDepth)) + } + for _, part := range parts { + if !isValidKeySegment(part) { + return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q", part)) } } - - c.mutex.Lock() - defer c.mutex.Unlock() - - c.items[path] = configItem{ - defaultValue: defaultValue, - currentValue: defaultValue, // Initially set to default - values: make(map[Source]any), - } - return nil } +func (c *Config) registerLocked(path string, value any) error { + for existing := range c.items { + if existing != path && (strings.HasPrefix(existing, path+".") || strings.HasPrefix(path, existing+".")) { + return wrapError(ErrInvalidPath, fmt.Errorf("overlapping registration %q and %q", path, existing)) + } + } + item, exists := c.items[path] + if !exists { + item.values = make(map[Source]any) + } + item.defaultValue = value + for _, v := range item.values { + if err := validateValue(item, v); err != nil { + return err + } + } + item.currentValue = c.computeValue(item) + c.items[path] = item + c.invalidateCache() + return nil +} + +func validateValue(item configItem, value any) error { + if item.defaultValue == nil || value == nil { + return nil + } + target := reflect.New(reflect.TypeOf(item.defaultValue)).Interface() + if err := decodeConfig(value, target); err != nil { + return wrapError(ErrTypeMismatch, err) + } + return nil +} + +func validSource(source Source) bool { + return source == SourceDefault || source == SourceFile || source == SourceEnv || source == SourceCLI +} + // RegisterWithEnv registers a path with an explicit environment variable mapping func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) error { if err := c.Register(path, defaultValue); err != nil { @@ -45,8 +86,7 @@ func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) e // Check if the environment variable exists and load it if value, exists := os.LookupEnv(envVar); exists { - parsed := parseValue(value) - return c.SetSource(SourceEnv, path, parsed) // Already wrapped with error category in SetSource + return c.SetSource(SourceEnv, path, value) // Already wrapped with error category in SetSource } return nil @@ -55,9 +95,22 @@ func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) e // RegisterRequired registers a path and marks it as required // The configuration will fail validation if this value is not provided func (c *Config) RegisterRequired(path string, defaultValue any) error { - // For now, just register normally - // The required paths will be tracked separately in a future enhancement - return c.Register(path, defaultValue) + if err := validatePath(path); err != nil { + return err + } + owned, err := copyValue(defaultValue) + if err != nil { + return err + } + c.mutex.Lock() + defer c.mutex.Unlock() + if err := c.registerLocked(path, owned); err != nil { + return err + } + item := c.items[path] + item.required = true + c.items[path] = item + return nil } // Unregister removes a configuration path and all its children @@ -84,12 +137,20 @@ func (c *Config) Unregister(path string) error { // Remove the path itself if it exists delete(c.items, path) + delete(c.fileData, path) + delete(c.envData, path) + delete(c.cliData, path) + + c.invalidateCache() // Remove any child paths prefix := path + "." for childPath := range c.items { if strings.HasPrefix(childPath, prefix) { delete(c.items, childPath) + delete(c.fileData, childPath) + delete(c.envData, childPath) + delete(c.cliData, childPath) } } @@ -121,26 +182,60 @@ func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, t // Validate tag name switch tagName { - case FormatTOML, FormatJSON, FormatYAML: + case FormatTOML: // Supported tags default: - return wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName)) + return wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml", tagName)) } var errors []string // Use helper function for recursive registration with specified tag - c.registerFields(v, prefix, "", &errors, tagName) + // Stage registrations and explicit env values so failures do not leave a partial schema. + staged := New() + staged.registerFields(v, prefix, "", &errors, tagName) if len(errors) > 0 { return wrapError(ErrTypeMismatch, fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; "))) } + c.mutex.Lock() + defer c.mutex.Unlock() + for path, item := range staged.items { + for existing := range c.items { + if existing != path && (strings.HasPrefix(existing, path+".") || strings.HasPrefix(path, existing+".")) { + return wrapError(ErrInvalidPath, fmt.Errorf("overlapping registration %q and %q", path, existing)) + } + } + if old, ok := c.items[path]; ok { + for _, value := range old.values { + if err := validateValue(item, value); err != nil { + return err + } + } + } + } + for path, item := range staged.items { + if old, ok := c.items[path]; ok { + for source, value := range old.values { + if _, provided := item.values[source]; !provided { + item.values[source] = value + } + } + } + item.currentValue = c.computeValue(item) + c.items[path] = item + } + c.invalidateCache() return nil } // registerFields is a helper function that handles the recursive field registration func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, errors *[]string, tagName string) { + if strings.Count(pathPrefix, ".") > maxValueDepth { + *errors = append(*errors, "recursive struct exceeds maximum depth") + return + } t := v.Type() for i := 0; i < v.NumField(); i++ { @@ -153,7 +248,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e // Get tag value based on tagName parameter tag := field.Tag.Get(tagName) - if tag == "-" { + if strings.Split(tag, ",")[0] == "-" { continue } @@ -187,21 +282,18 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e if isStruct || isPtrToStruct { // Check if the field's TYPE is one that should be treated as a single value, // even though it's a struct. These types have custom decode hooks - fieldType := fieldValue.Type() - isAtomicStruct := false - switch fieldType.String() { - case "time.Time", "*net.IPNet", "*url.URL", "net.IP": // Match the exact type names - isAtomicStruct = true - } + isAtomicStruct := atomicType(fieldValue.Type()) // Only recurse if it's a "normal" struct, not an atomic one if !isAtomicStruct { nestedValue := fieldValue if isPtrToStruct { if fieldValue.IsNil() { - continue // Skip nil pointers in the default struct + nestedValue = reflect.New(fieldType.Elem()).Elem() + } + if !fieldValue.IsNil() { + nestedValue = fieldValue.Elem() } - nestedValue = fieldValue.Elem() } nestedPrefix := currentPath + "." @@ -212,6 +304,10 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e } // Register non-struct fields + if _, exists := c.items[currentPath]; exists { + *errors = append(*errors, fmt.Sprintf("duplicate TOML path %q", currentPath)) + continue + } defaultValue := fieldValue.Interface() var err error @@ -228,8 +324,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e // Handle explicit env tag if envTag != "" && err == nil { if value, exists := os.LookupEnv(envTag); exists { - parsed := parseValue(value) - if setErr := c.SetSource(SourceEnv, currentPath, parsed); setErr != nil { + if setErr := c.SetSource(SourceEnv, currentPath, value); setErr != nil { *errors = append(*errors, fmt.Sprintf("field %s%s env %s: %v", fieldPath, field.Name, envTag, setErr)) } } @@ -270,7 +365,7 @@ func (c *Config) GetRegisteredPathsWithDefaults(prefix ...string) map[string]any result := make(map[string]any) for path, item := range c.items { if strings.HasPrefix(path, p) { - result[path] = item.defaultValue + result[path] = cloneOwned(item.defaultValue) } } @@ -285,4 +380,4 @@ func (c *Config) Scan(target any, basePath ...string) error { // ScanSource decodes configuration from specific source using unified unmarshal func (c *Config) ScanSource(source Source, target any, basePath ...string) error { return c.unmarshal(source, target, basePath...) -} \ No newline at end of file +} diff --git a/utility.go b/utility.go index 438aac4..f08dcd8 100644 --- a/utility.go +++ b/utility.go @@ -8,10 +8,6 @@ import ( "os" "reflect" "strings" - "time" - - "github.com/BurntSushi/toml" - "github.com/go-viper/mapstructure/v2" ) // Quick creates a fully configured Config instance with a single call @@ -89,27 +85,21 @@ func (c *Config) GenerateFlags() *flag.FlagSet { // BindFlags updates configuration from parsed flag.FlagSet func (c *Config) BindFlags(fs *flag.FlagSet) error { - var errors []error - needsInvalidation := false - - fs.Visit(func(f *flag.Flag) { - value := f.Value.String() - // Let mapstructure handle type conversion - if err := c.SetSource(SourceCLI, f.Name, value); err != nil { - errors = append(errors, fmt.Errorf("flag %s: %w", f.Name, err)) - } else { - needsInvalidation = true + if fs == nil { + return wrapError(ErrCLIParse, fmt.Errorf("nil flag set")) + } + values := make(map[string]any) + fs.Visit(func(f *flag.Flag) { values[f.Name] = f.Value.String() }) + c.mutex.Lock() + defer c.mutex.Unlock() + for path := range values { + if _, ok := c.items[path]; !ok { + return wrapError(ErrCLIParse, fmt.Errorf("failed to bind unregistered flag %q: %w", path, ErrPathNotRegistered)) } - }) - - if needsInvalidation { - c.invalidateCache() // Batch invalidation after all flags } - - if len(errors) > 0 { - return wrapError(ErrCLIParse, fmt.Errorf("failed to bind %d flags: %w", len(errors), errors[0])) + if err := c.replaceSourceLocked(SourceCLI, values); err != nil { + return wrapError(ErrCLIParse, err) } - return nil } @@ -120,6 +110,13 @@ func (c *Config) Validate(required ...string) error { defer c.mutex.RUnlock() var missing []string + if len(required) == 0 { + for path, item := range c.items { + if item.required { + required = append(required, path) + } + } + } for _, path := range required { item, exists := c.items[path] @@ -132,7 +129,11 @@ func (c *Config) Validate(required ...string) error { if reflect.DeepEqual(item.currentValue, item.defaultValue) { // Check if any source provided a value hasValue := false - for _, val := range item.values { + for _, source := range c.options.Sources { + if source == SourceDefault { + break + } + val := item.values[source] if val != nil { hasValue = true break @@ -177,59 +178,50 @@ func (c *Config) Debug() string { // Dump writes the current configuration to stdout in TOML format func (c *Config) Dump() error { c.mutex.RLock() - defer c.mutex.RUnlock() + nestedData := c.nestedLocked("") + c.mutex.RUnlock() - nestedData := make(map[string]any) - for path, item := range c.items { - setNestedValue(nestedData, path, item.currentValue) + data, err := marshalConfig(nestedData) + if err != nil { + return err } - - encoder := toml.NewEncoder(os.Stdout) - if err := encoder.Encode(nestedData); err != nil { - return wrapError(ErrDecode, err) - } - return nil + _, err = os.Stdout.Write(data) + return err } // Clone creates a deep copy of the configuration func (c *Config) Clone() *Config { c.mutex.RLock() defer c.mutex.RUnlock() - - clone := &Config{ - items: make(map[string]configItem), - options: c.options, - fileData: make(map[string]any), - envData: make(map[string]any), - cliData: make(map[string]any), + clone := NewWithOptions(c.options) + clone.tagName, clone.fileFormat = c.tagName, c.fileFormat + clone.configFilePath = c.configFilePath + clone.fileComments = append([]string(nil), c.fileComments...) + clone.unknownCLIKeys = append([]string(nil), c.unknownCLIKeys...) + if c.securityOpts != nil { + opts := *c.securityOpts + clone.securityOpts = &opts } - - // Deep copy items for path, item := range c.items { - newItem := configItem{ - defaultValue: item.defaultValue, - currentValue: item.currentValue, - values: make(map[Source]any), - } - + next := configItem{defaultValue: cloneOwned(item.defaultValue), currentValue: cloneOwned(item.currentValue), values: make(map[Source]any)} for source, value := range item.values { - newItem.values[source] = value + next.values[source] = cloneOwned(value) } - - clone.items[path] = newItem + next.required = item.required + clone.items[path] = next } - - // Copy cache data for k, v := range c.fileData { - clone.fileData[k] = v + clone.fileData[k] = cloneOwned(v) } for k, v := range c.envData { - clone.envData[k] = v + clone.envData[k] = cloneOwned(v) } for k, v := range c.cliData { - clone.cliData[k] = v + clone.cliData[k] = cloneOwned(v) + } + if c.structCache != nil { + clone.structCache = &structCache{targetType: c.structCache.targetType, prefix: c.structCache.prefix} } - return clone } @@ -253,62 +245,42 @@ func GetTyped[T any](c *Config, path string) (T, error) { return zero, wrapError(ErrPathNotFound, fmt.Errorf("path %q not found", path)) } - // Prepare the input map and target struct for the decoder - inputMap := map[string]any{"value": rawValue} - var target struct { - Value T `mapstructure:"value"` + var target T + if err := decodeConfig(rawValue, &target); err != nil { + return zero, err } - - // Create a new decoder configured with the same hooks as the main config - decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ - Result: &target, - TagName: c.tagName, - WeaklyTypedInput: true, - DecodeHook: c.getDecodeHook(), - Metadata: nil, - }) - if err != nil { - return zero, wrapError(ErrDecode, fmt.Errorf("failed to create decoder for path %q: %w", path, err)) - } - - // Decode the single value. - if err := decoder.Decode(inputMap); err != nil { - return zero, wrapError(ErrDecode, fmt.Errorf("failed to decode value for path %q into type %T: %w", path, zero, err)) - } - - return target.Value, nil + return target, nil } // GetTypedWithDefault retrieves a configuration value with a default fallback // If the path doesn't exist or isn't set, it sets and returns the default value // For simple cases where explicit defaults aren't pre-registered func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) { - // Check if path exists and has a value - if _, exists := c.Get(path); exists { - // Path exists, try to decode the current value - result, err := GetTyped[T](c, path) - if err == nil { - return result, nil + c.mutex.Lock() + if item, exists := c.items[path]; exists { + raw := item.currentValue + c.mutex.Unlock() + var result T + if err := decodeConfig(raw, &result); err != nil { + return result, err } - // Type conversion failed, fall through to set default + return result, nil } - - // Path doesn't exist or value not set - register and set default - // This handles the case where the path wasn't pre-registered - if err := c.Register(path, defaultValue); err != nil { - // Path might already be registered with incompatible type - // Try to just set the value - if setErr := c.Set(path, defaultValue); setErr != nil { - return defaultValue, wrapError(ErrPathNotRegistered, fmt.Errorf("%w : failed to register or set default for path %q", ErrPathNotRegistered, path)) - } + if err := validatePath(path); err != nil { + c.mutex.Unlock() + return defaultValue, err } - - // Set the default value - if err := c.Set(path, defaultValue); err != nil { - return defaultValue, wrapError(ErrTypeMismatch, fmt.Errorf("%w : failed to set default value for path %q", ErrTypeMismatch, path)) + owned, err := copyValue(defaultValue) + if err == nil { + err = c.registerLocked(path, owned) } - - return defaultValue, nil + c.mutex.Unlock() + if err != nil { + return defaultValue, err + } + var result T + err = decodeConfig(owned, &result) + return result, err } // ScanTyped is a generic wrapper around Scan. It allocates a new instance of type T, @@ -325,41 +297,8 @@ func ScanTyped[T any](c *Config, basePath ...string) (*T, error) { // without requiring a full Config instance. This is useful for plugin // initialization where config data arrives as a map[string]any. func ScanMap(configMap map[string]any, target any, tagName ...string) error { - // Handle nil map - if configMap == nil { - configMap = make(map[string]any) + if len(tagName) > 1 || len(tagName) == 1 && tagName[0] != "" && tagName[0] != FormatTOML { + return wrapError(ErrTypeMismatch, fmt.Errorf("only toml tags are supported")) } - - // Determine tag name - tag := "toml" // default - if len(tagName) > 0 && tagName[0] != "" { - tag = tagName[0] - } - - // Create decoder with standard hooks - decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ - Result: target, - TagName: tag, - WeaklyTypedInput: true, - DecodeHook: mapstructure.ComposeDecodeHookFunc( - jsonNumberHookFunc(), - stringToNetIPHookFunc(), - stringToNetIPNetHookFunc(), - stringToURLHookFunc(), - mapstructure.StringToTimeDurationHookFunc(), - mapstructure.StringToTimeHookFunc(time.RFC3339), - mapstructure.StringToSliceHookFunc(","), - ), - ZeroFields: true, - }) - if err != nil { - return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err)) - } - - // Decode directly - if err := decoder.Decode(configMap); err != nil { - return wrapError(ErrDecode, fmt.Errorf("decode failed: %w", err)) - } - - return nil -} \ No newline at end of file + return decodeConfig(configMap, target) +} diff --git a/utility_test.go b/utility_test.go index 9f36b81..ae7dc16 100644 --- a/utility_test.go +++ b/utility_test.go @@ -162,7 +162,7 @@ func TestFlagGeneration(t *testing.T) { err := cfg.BindFlags(fs) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to bind 1 flags") + assert.Contains(t, err.Error(), "unregistered flag") }) } @@ -403,24 +403,9 @@ func TestScanMap(t *testing.T) { assert.Equal(t, "info", target.LogLevel) }) - t.Run("ScanWithJSONTags", func(t *testing.T) { - configMap := map[string]any{ - "server": map[string]any{ - "hostname": "json-host", - "port": 9090, - "timeout": "1m", - }, - "logLevel": "debug", - } - + t.Run("RejectJSONTags", func(t *testing.T) { var target Config - err := ScanMap(configMap, &target, "json") - - require.NoError(t, err) - assert.Equal(t, "json-host", target.Server.Host) - assert.Equal(t, 9090, target.Server.Port) - assert.Equal(t, 1*time.Minute, target.Server.Timeout) - assert.Equal(t, "debug", target.LogLevel) + require.Error(t, ScanMap(map[string]any{}, &target, "json")) }) t.Run("NilMapInput", func(t *testing.T) { @@ -463,7 +448,7 @@ func TestScanMap(t *testing.T) { err := ScanMap(configMap, target) assert.Error(t, err) - // The underlying mapstructure error is "result must be a pointer" - assert.Contains(t, err.Error(), "must be a pointer") + // Invalid targets return a categorized type error. + assert.Contains(t, err.Error(), "must be non-nil pointer") }) -} \ No newline at end of file +} diff --git a/validator.go b/validator.go index 97aab57..6191193 100644 --- a/validator.go +++ b/validator.go @@ -3,6 +3,7 @@ package config import ( "fmt" + "math" "net" "regexp" "strings" @@ -19,16 +20,16 @@ func Port(p int64) error { } // Positive validates positive numbers -func Positive[T int64 | float64](n T) error { - if n <= 0 { +func Positive[T ~int64 | ~float64](n T) error { + if n <= 0 || math.IsNaN(float64(n)) || math.IsInf(float64(n), 0) { return wrapError(ErrValidation, fmt.Errorf("must be positive, got %v", n)) } return nil } // NonNegative validates non-negative numbers -func NonNegative[T int64 | float64](n T) error { - if n < 0 { +func NonNegative[T ~int64 | ~float64](n T) error { + if n < 0 || math.IsNaN(float64(n)) || math.IsInf(float64(n), 0) { return wrapError(ErrValidation, fmt.Errorf("must be non-negative, got %v", n)) } return nil @@ -94,9 +95,9 @@ func OneOf[T comparable](allowed ...T) func(T) error { } // Range creates a min/max validator -func Range[T int64 | float64](min, max T) func(T) error { +func Range[T ~int64 | ~float64](min, max T) func(T) error { return func(val T) error { - if val < min || val > max { + if min > max || math.IsNaN(float64(min)) || math.IsNaN(float64(max)) || math.IsNaN(float64(val)) || math.IsInf(float64(val), 0) || val < min || val > max { return wrapError(ErrValidation, fmt.Errorf("must be %v-%v, got %v", min, max, val)) } return nil @@ -120,4 +121,4 @@ func NonEmpty(s string) error { return wrapError(ErrValidation, fmt.Errorf("must not be empty")) } return nil -} \ No newline at end of file +} diff --git a/value.go b/value.go new file mode 100644 index 0000000..42c3e74 --- /dev/null +++ b/value.go @@ -0,0 +1,162 @@ +package config + +import ( + "fmt" + "math" + "net" + "net/url" + "reflect" + "time" + "unicode/utf8" +) + +const maxValueDepth = 128 + +var ( + timeType = reflect.TypeFor[time.Time]() + durationType = reflect.TypeFor[time.Duration]() + ipType = reflect.TypeFor[net.IP]() + ipNetType = reflect.TypeFor[net.IPNet]() + urlType = reflect.TypeFor[url.URL]() + userInfoType = reflect.TypeFor[url.Userinfo]() +) + +func atomicType(t reflect.Type) bool { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + return t == timeType || t == ipType || t == ipNetType || t == urlType +} + +// copyValue validates the supported value graph and detaches mutable containers. +// The depth bound rejects cycles without retaining a global identity cache. +func copyValue(v any) (any, error) { + if v == nil { + return nil, nil + } + out, err := copyReflect(reflect.ValueOf(v), 0) + if err != nil { + return nil, wrapError(ErrTypeMismatch, err) + } + return out.Interface(), nil +} + +func copyReflect(v reflect.Value, depth int) (reflect.Value, error) { + if depth > maxValueDepth { + return reflect.Value{}, fmt.Errorf("value nesting exceeds %d (possible cycle)", maxValueDepth) + } + if v.Type() == ipType && v.Len() != 0 && v.Interface().(net.IP).To16() == nil { + return reflect.Value{}, fmt.Errorf("invalid IP value") + } + if v.Type() == ipNetType { + n := v.Interface().(net.IPNet) + _, bits := n.Mask.Size() + if (len(n.IP) != 0 || len(n.Mask) != 0) && (n.IP.To16() == nil || bits == 0 || bits == 32 && n.IP.To4() == nil) { + return reflect.Value{}, fmt.Errorf("invalid CIDR value") + } + } + if v.Type() == timeType { + if _, err := v.Interface().(time.Time).MarshalText(); err != nil { + return reflect.Value{}, err + } + } + out := reflect.New(v.Type()).Elem() + switch v.Kind() { + case reflect.Interface, reflect.Pointer: + if v.IsNil() { + return out, nil + } + elem, err := copyReflect(v.Elem(), depth+1) + if err != nil { + return reflect.Value{}, err + } + if v.Kind() == reflect.Pointer { + out.Set(reflect.New(v.Type().Elem())) + out.Elem().Set(elem) + } else { + out.Set(elem) + } + case reflect.Map: + if v.Type().Key().Kind() != reflect.String { + return reflect.Value{}, fmt.Errorf("map keys must be strings") + } + if v.IsNil() { + return out, nil + } + out.Set(reflect.MakeMapWithSize(v.Type(), v.Len())) + it := v.MapRange() + for it.Next() { + if !utf8.ValidString(it.Key().String()) { + return reflect.Value{}, fmt.Errorf("invalid UTF-8 map key") + } + x, err := copyReflect(it.Value(), depth+1) + if err != nil { + return reflect.Value{}, err + } + out.SetMapIndex(it.Key(), x) + } + case reflect.Slice, reflect.Array: + if v.Kind() == reflect.Slice { + if v.IsNil() { + return out, nil + } + out.Set(reflect.MakeSlice(v.Type(), v.Len(), v.Len())) + } + for i := 0; i < v.Len(); i++ { + x, err := copyReflect(v.Index(i), depth+1) + if err != nil { + return reflect.Value{}, err + } + out.Index(i).Set(x) + } + case reflect.Struct: + // These standard-library values contain immutable private state. + if v.Type() == timeType || v.Type() == userInfoType { + out.Set(v) + return out, nil + } + exported := false + for i := 0; i < v.NumField(); i++ { + if !v.Type().Field(i).IsExported() { + continue + } + exported = true + x, err := copyReflect(v.Field(i), depth+1) + if err != nil { + return reflect.Value{}, err + } + out.Field(i).Set(x) + } + if !exported && v.NumField() != 0 { + return reflect.Value{}, fmt.Errorf("unsupported private state in %s", v.Type()) + } + case reflect.String: + if len(v.String()) > MaxValueSize { + return reflect.Value{}, ErrValueSize + } + if !utf8.ValidString(v.String()) { + return reflect.Value{}, fmt.Errorf("invalid UTF-8 string") + } + out.Set(v) + case reflect.Float32, reflect.Float64: + if math.IsNaN(v.Float()) || math.IsInf(v.Float(), 0) { + return reflect.Value{}, fmt.Errorf("non-finite float") + } + out.Set(v) + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + out.Set(v) + default: + return reflect.Value{}, fmt.Errorf("unsupported value type %s", v.Type()) + } + return out, nil +} + +// cloneOwned is used only on values already accepted by copyValue. +func cloneOwned(v any) any { + out, err := copyValue(v) + if err != nil { + panic("config: invalid internal value: " + err.Error()) + } + return out +} diff --git a/watch.go b/watch.go index a9fed1c..eca2e75 100644 --- a/watch.go +++ b/watch.go @@ -1,240 +1,146 @@ -// FILE: lixenwraith/config/watch.go package config import ( "context" + "errors" "fmt" "os" "reflect" + "slices" "sync" "sync/atomic" "time" ) -// WatchOptions configures file watching behavior type WatchOptions struct { - // PollInterval for file stat checks (minimum 100ms) - PollInterval time.Duration - - // Debounce duration to avoid rapid reloads - Debounce time.Duration - - // MaxWatchers limits concurrent watch channels - MaxWatchers int - - // ReloadTimeout for file reload operations - ReloadTimeout time.Duration - - // VerifyPermissions checks file hasn't been replaced with different permissions + PollInterval time.Duration // Minimum 100ms; each poll reads and fingerprints the file. + Debounce time.Duration // Quiet interval before publishing a value or error. + MaxWatchers int + ReloadTimeout time.Duration // Expired reads/parses cannot publish. VerifyPermissions bool } -// DefaultWatchOptions returns sensible defaults for file watching func DefaultWatchOptions() WatchOptions { - return WatchOptions{ - PollInterval: DefaultPollInterval, - Debounce: DefaultDebounce, - MaxWatchers: DefaultMaxWatchers, - ReloadTimeout: DefaultReloadTimeout, - VerifyPermissions: true, - } + return WatchOptions{DefaultPollInterval, DefaultDebounce, DefaultMaxWatchers, DefaultReloadTimeout, true} } -// watcher manages file watching state type watcher struct { - mu sync.RWMutex - ctx context.Context - cancel context.CancelFunc - opts WatchOptions - filePath string - lastModTime time.Time - lastSize int64 - lastMode os.FileMode - watching atomic.Bool - reloadInProgress atomic.Bool - watchers map[int64]chan string // subscriber channels - watcherID atomic.Int64 - debounceTimer *time.Timer + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + opts WatchOptions + filePath string + lastMode os.FileMode + watching atomic.Bool + watchers map[int64]chan string + watcherID int64 + observed string + handled string + timeoutKey string + observedAt time.Time + read func(context.Context, string, fileSettings) (*fileSnapshot, error) } -// configWatcher extends Config with watching capabilities -type configWatcher struct { - *Config - watcher *watcher -} - -// AutoUpdate enables automatic configuration reloading when the file changes -func (c *Config) AutoUpdate() { - c.AutoUpdateWithOptions(DefaultWatchOptions()) -} - -// AutoUpdateWithOptions enables automatic configuration reloading with custom options -func (c *Config) AutoUpdateWithOptions(opts WatchOptions) { - // Validate options +func normalizeWatchOptions(opts WatchOptions) WatchOptions { if opts.PollInterval < MinPollInterval { opts.PollInterval = MinPollInterval } + if opts.Debounce < 0 { + opts.Debounce = 0 + } if opts.MaxWatchers <= 0 { - opts.MaxWatchers = 100 + opts.MaxWatchers = DefaultMaxWatchers } if opts.ReloadTimeout <= 0 { opts.ReloadTimeout = DefaultReloadTimeout } + return opts +} +func (c *Config) AutoUpdate() { c.AutoUpdateWithOptions(DefaultWatchOptions()) } +func (c *Config) AutoUpdateWithOptions(opts WatchOptions) { c.mutex.Lock() defer c.mutex.Unlock() - - // Get path of current file to watch - filePath := c.getConfigFilePath() - if filePath == "" { - // No file configured, nothing to watch + c.startWatcherLocked(opts) +} +func (c *Config) startWatcherLocked(opts WatchOptions) { + if c.configFilePath == "" { return } - - // Stop existing watcher if path changed - if c.watcher != nil && c.watcher.filePath != filePath { + if c.watcher != nil { + if c.watcher.filePath == c.configFilePath && c.watcher.watching.Load() { + return + } c.watcher.stop() - c.watcher = nil } - - // Initialize watcher if needed - if c.watcher == nil { - ctx, cancel := context.WithCancel(context.Background()) - c.watcher = &watcher{ - ctx: ctx, - cancel: cancel, - opts: opts, - filePath: filePath, - watchers: make(map[int64]chan string), - } - - // Get initial file state - if info, err := os.Stat(filePath); err == nil { - c.watcher.lastModTime = info.ModTime() - c.watcher.lastSize = info.Size() - c.watcher.lastMode = info.Mode() - } - - // Start watching - go c.watcher.watchLoop(c) + ctx, cancel := context.WithCancel(context.Background()) + w := &watcher{ctx: ctx, cancel: cancel, opts: normalizeWatchOptions(opts), filePath: c.configFilePath, watchers: make(map[int64]chan string), read: readConfigFile} + if info, err := os.Stat(w.filePath); err == nil { + w.lastMode = info.Mode() } + w.watching.Store(true) + c.watcher = w + go w.watchLoop(c) } -// StopAutoUpdate stops automatic configuration reloading func (c *Config) StopAutoUpdate() { c.mutex.Lock() defer c.mutex.Unlock() - if c.watcher != nil { c.watcher.stop() c.watcher = nil } } - -// Watch returns a channel that receives paths of changed configuration values -func (c *Config) Watch() <-chan string { - return c.WatchWithOptions(DefaultWatchOptions()) +func (c *Config) Watch() <-chan string { return c.WatchWithOptions(DefaultWatchOptions()) } +func (c *Config) WatchWithOptions(opts WatchOptions) <-chan string { + c.mutex.Lock() + defer c.mutex.Unlock() + c.startWatcherLocked(opts) + if c.watcher == nil { + return closedWatchChannel() + } + return c.watcher.subscribe() } - -// WatchFile stops any existing file watcher, loads a new configuration file, -// and starts a new watcher on that file path. Optionally accepts format hint -func (c *Config) WatchFile(filePath string, formatHint ...string) error { - // Stop any currently running watcher - c.StopAutoUpdate() - - // Set format hint if provided - if len(formatHint) > 0 { +func (c *Config) WatchFile(path string, formatHint ...string) error { + if len(formatHint) > 1 { + return ErrFileFormat + } + if len(formatHint) == 1 { if err := c.SetFileFormat(formatHint[0]); err != nil { - return wrapError(ErrFileFormat, fmt.Errorf("invalid format hint: %w", err)) + return err } } - - // Load the new file - if err := c.LoadFile(filePath); err != nil { - return fmt.Errorf("failed to load new file for watching: %w", err) // Already wrapped with error category in LoadFile - } - - // Get previous watcher options if available c.mutex.RLock() opts := DefaultWatchOptions() if c.watcher != nil { opts = c.watcher.opts } c.mutex.RUnlock() - - // Create and start a new watcher with the new file path + // A failed load leaves the existing watcher and valid state intact. + if err := c.LoadFile(path); err != nil { + return err + } c.AutoUpdateWithOptions(opts) return nil } - -// WatchWithOptions returns a channel with custom watch options -// should not restart the watcher if it's already running with the same file -func (c *Config) WatchWithOptions(opts WatchOptions) <-chan string { - c.mutex.RLock() - watcher := c.watcher - filePath := c.configFilePath - c.mutex.RUnlock() - - // If no file configured, return closed channel - if filePath == "" { - ch := make(chan string) - close(ch) - return ch - } - - // If watcher exists and is watching the current file, just subscribe - if watcher != nil && watcher.filePath == filePath && watcher.watching.Load() { - return watcher.subscribe() - } - - // First ensure auto-update is running - c.AutoUpdateWithOptions(opts) - - c.mutex.RLock() - watcher = c.watcher - c.mutex.RUnlock() - - if watcher == nil { - // No file to watch, return closed channel - ch := make(chan string) - close(ch) - return ch - } - - return watcher.subscribe() -} - -// IsWatching returns true if auto-update is enabled func (c *Config) IsWatching() bool { c.mutex.RLock() defer c.mutex.RUnlock() return c.watcher != nil && c.watcher.watching.Load() } - -// WatcherCount returns the number of active watch channels func (c *Config) WatcherCount() int { c.mutex.RLock() defer c.mutex.RUnlock() - if c.watcher == nil { return 0 } - - c.watcher.mu.RLock() - defer c.watcher.mu.RUnlock() + c.watcher.mu.Lock() + defer c.watcher.mu.Unlock() return len(c.watcher.watchers) } - -// watchLoop is the main file watching loop func (w *watcher) watchLoop(c *Config) { - if !w.watching.CompareAndSwap(false, true) { - return // Already watching - } - defer w.watching.Store(false) - ticker := time.NewTicker(w.opts.PollInterval) defer ticker.Stop() - for { select { case <-w.ctx.Done(): @@ -245,188 +151,147 @@ func (w *watcher) watchLoop(c *Config) { } } -// checkAndReload checks if file changed and triggers reload +// One loop owns observations and debounce state. I/O runs outside the Config lock; +// publication checks context, watcher identity and file generation under that lock. func (w *watcher) checkAndReload(c *Config) { - info, err := os.Stat(w.filePath) - if err != nil { - if os.IsNotExist(err) { - // File was deleted, notify watchers - w.notifyWatchers(EventFileDeleted) - } + if w.ctx.Err() != nil { return } - - // Check for changes - changed := false - - // Compare modification time and size - if !info.ModTime().Equal(w.lastModTime) || info.Size() != w.lastSize { - changed = true - } - - // SECURITY: Verify permissions haven't changed suspiciously - if w.opts.VerifyPermissions && w.lastMode != 0 { - if info.Mode() != w.lastMode { - // Permission change detected - if (info.Mode() & 0077) != (w.lastMode & 0077) { - // World/group permissions changed - potential security issue - w.notifyWatchers(EventPermissionsChanged) - // Don't reload on permission change for security - return - } - } - } - - if changed { - // Update tracked state - w.lastModTime = info.ModTime() - w.lastSize = info.Size() - w.lastMode = info.Mode() - - // Debounce rapid changes - w.mu.Lock() - if w.debounceTimer != nil { - w.debounceTimer.Stop() - } - w.debounceTimer = time.AfterFunc(w.opts.Debounce, func() { - w.performReload(c) - }) - w.mu.Unlock() - } -} - -// performReload reloads the configuration file -func (w *watcher) performReload(c *Config) { - // Prevent concurrent reloads - if !w.reloadInProgress.CompareAndSwap(false, true) { - return - } - defer w.reloadInProgress.Store(false) - - // Create a timeout context for reload ctx, cancel := context.WithTimeout(w.ctx, w.opts.ReloadTimeout) defer cancel() - - // Track what changed - oldValues := c.snapshot() - - // Reload file in a goroutine with timeout - done := make(chan error, 1) - go func() { - done <- c.loadFile(w.filePath) - }() - - select { - case err := <-done: - if err != nil { - // Reload failed, notify error - w.notifyWatchers(fmt.Sprintf("%s:%v", EventReloadError, err)) - return + c.mutex.RLock() + opts := c.fileSettingsLocked() + generation := c.fileGeneration + c.mutex.RUnlock() + snapshot, readErr := w.read(ctx, w.filePath, opts) + key := "" + event := "" + if readErr != nil { + key = "error:" + readErr.Error() + switch { + case errors.Is(readErr, context.DeadlineExceeded): + event = EventReloadTimeout + case errors.Is(readErr, ErrConfigNotFound): + event = EventFileDeleted + default: + event = EventReloadError + ":" + readErr.Error() } - - // Compare and notify changes - newValues := c.snapshot() - for path, newVal := range newValues { - if oldVal, existed := oldValues[path]; !existed || !reflect.DeepEqual(oldVal, newVal) { - w.notifyWatchers(path) - } + } else { + key = fmt.Sprintf("%x:%o", snapshot.digest, snapshot.info.Mode()) + if w.opts.VerifyPermissions && w.lastMode != 0 && (snapshot.info.Mode()&0077) != (w.lastMode&0077) { + event = EventPermissionsChanged } - - // Check for deletions - for path := range oldValues { - if _, exists := newValues[path]; !exists { - w.notifyWatchers(path) - } + } + if key != w.observed { + w.observed, w.observedAt = key, time.Now() + } + if key == w.handled || time.Since(w.observedAt) < w.opts.Debounce { + return + } + if w.ctx.Err() != nil { + return + } + if event != "" { + if event == EventReloadTimeout { + w.notifyTimeout(key) + } else { + w.notifyWatchers(event) + w.handled = key } - - case <-ctx.Done(): - // Reload timeout - w.notifyWatchers(EventReloadTimeout) + return + } + root, comments, err := parseFile(snapshot) + if err != nil { + w.notifyWatchers(EventReloadError + ":" + err.Error()) + w.handled = key + return + } + c.mutex.Lock() + defer c.mutex.Unlock() + if c.watcher != w || c.configFilePath != w.filePath || c.fileGeneration != generation || w.ctx.Err() != nil { + return + } + if ctx.Err() != nil { + w.notifyTimeout(key) + return + } + old := make(map[string]any, len(c.items)) + for path, item := range c.items { + old[path] = item.currentValue + } + if err := c.applyFileLocked(root, comments, w.filePath, ctx.Err); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + w.notifyTimeout(key) + } else { + w.notifyWatchers(EventReloadError + ":" + err.Error()) + w.handled = key + } + return + } + w.lastMode, w.handled = snapshot.info.Mode(), key + w.timeoutKey = "" + var changed []string + for path, item := range c.items { + if !reflect.DeepEqual(old[path], item.currentValue) { + changed = append(changed, path) + } + } + slices.Sort(changed) + for _, path := range changed { + w.notifyWatchers(path) } } -// subscribe creates a new watcher channel +// A timeout is retryable even when the file contents have not changed. Suppress +// repeated notifications for the same observation without suppressing its retry. +func (w *watcher) notifyTimeout(key string) { + if w.timeoutKey != key { + w.notifyWatchers(EventReloadTimeout) + w.timeoutKey = key + } +} + +func closedWatchChannel() <-chan string { ch := make(chan string); close(ch); return ch } func (w *watcher) subscribe() <-chan string { w.mu.Lock() defer w.mu.Unlock() - - // Check watcher limit - if len(w.watchers) >= w.opts.MaxWatchers { - // Return closed channel to prevent resource exhaustion - ch := make(chan string) - close(ch) - return ch + if w.ctx.Err() != nil || len(w.watchers) >= w.opts.MaxWatchers { + return closedWatchChannel() } - - // Create buffered channel to prevent blocking ch := make(chan string, WatchChannelBuffer) - id := w.watcherID.Add(1) - w.watchers[id] = ch - - // Cleanup goroutine - go func() { - <-w.ctx.Done() - w.mu.Lock() - delete(w.watchers, id) - close(ch) - w.mu.Unlock() - }() - + w.watcherID++ + w.watchers[w.watcherID] = ch return ch } - -// notifyWatchers sends change notification to all subscribers func (w *watcher) notifyWatchers(path string) { - w.mu.RLock() - defer w.mu.RUnlock() - - for id, ch := range w.watchers { + w.mu.Lock() + defer w.mu.Unlock() + if w.ctx.Err() != nil { + return + } + for _, ch := range w.watchers { select { case ch <- path: - // Sent successfully default: - // Channel full or closed, skip - // Could implement removal of dead watchers here - _ = id } } } - -// stop terminates the watcher func (w *watcher) stop() { - if w.cancel != nil { - w.cancel() - } - - // Stop debounce timer w.mu.Lock() - if w.debounceTimer != nil { - w.debounceTimer.Stop() - w.debounceTimer = nil - } - w.mu.Unlock() - - // Wait for watch loop to exit with timeout - deadline := time.Now().Add(ShutdownTimeout) - for w.watching.Load() && time.Now().Before(deadline) { - time.Sleep(SpinWaitInterval) + defer w.mu.Unlock() + w.cancel() + w.watching.Store(false) + for id, ch := range w.watchers { + close(ch) + delete(w.watchers, id) } } - -// getConfigFilePath returns the current config file path -func (c *Config) getConfigFilePath() string { - // Access the tracked config file path - return c.configFilePath -} - -// snapshot creates a snapshot of current values func (c *Config) snapshot() map[string]any { c.mutex.RLock() defer c.mutex.RUnlock() - - snapshot := make(map[string]any, len(c.items)) + out := make(map[string]any, len(c.items)) for path, item := range c.items { - snapshot[path] = item.currentValue + out[path] = cloneOwned(item.currentValue) } - return snapshot -} \ No newline at end of file + return out +} diff --git a/watch_test.go b/watch_test.go index 6d66636..fe02d48 100644 --- a/watch_test.go +++ b/watch_test.go @@ -516,4 +516,4 @@ func BenchmarkWatchOverhead(b *testing.B) { for i := 0; i < b.N; i++ { _, _ = cfg.Get(fmt.Sprintf("value%d", i%100)) } -} \ No newline at end of file +} diff --git a/watcher_hardening_test.go b/watcher_hardening_test.go new file mode 100644 index 0000000..87f5cb2 --- /dev/null +++ b/watcher_hardening_test.go @@ -0,0 +1,258 @@ +package config + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func manualWatcher(t *testing.T, c *Config, path string) *watcher { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + w := &watcher{ctx: ctx, cancel: cancel, opts: WatchOptions{ReloadTimeout: time.Second, MaxWatchers: 10}, filePath: path, watchers: make(map[int64]chan string), read: readConfigFile} + w.watching.Store(true) + c.mutex.Lock() + c.watcher = w + c.mutex.Unlock() + t.Cleanup(c.StopAutoUpdate) + return w +} + +func watcherFixture(t *testing.T) (*Config, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte("value=1\n"), 0600); err != nil { + t.Fatal(err) + } + c := New() + if err := c.Register("value", uint8(0)); err != nil { + t.Fatal(err) + } + if err := c.LoadFile(path); err != nil { + t.Fatal(err) + } + return c, path +} + +func TestReloadCannotPublishAfterStopTimeoutOrNewLoad(t *testing.T) { + for _, mode := range []string{"stop", "timeout", "new-load"} { + t.Run(mode, func(t *testing.T) { + c, path := watcherFixture(t) + w := manualWatcher(t, c, path) + ch := w.subscribe() + if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil { + t.Fatal(err) + } + staged, err := readConfigFile(context.Background(), path, fileSettings{}) + if err != nil { + t.Fatal(err) + } + w.read = func(ctx context.Context, _ string, _ fileSettings) (*fileSnapshot, error) { + switch mode { + case "stop": + c.StopAutoUpdate() + case "timeout": + <-ctx.Done() + case "new-load": + if err := os.WriteFile(path, []byte("value=3\n"), 0600); err != nil { + t.Fatal(err) + } + if err := c.LoadFile(path); err != nil { + t.Fatal(err) + } + } + return staged, nil // Deliberately complete a stale read successfully. + } + if mode == "timeout" { + w.opts.ReloadTimeout = time.Millisecond + } + w.checkAndReload(c) + want := int64(1) + if mode == "new-load" { + want = 3 + } + if value, _ := c.Get("value"); value != want { + t.Fatalf("stale publication: %v want %v", value, want) + } + if mode == "timeout" { + select { + case event := <-ch: + if event != EventReloadTimeout { + t.Fatal(event) + } + default: + t.Fatal("timeout event missing") + } + } + if mode == "stop" { + if _, ok := <-ch; ok { + t.Fatal("subscriber not closed by stop") + } + } + }) + } +} + +func TestWatcherDebouncesErrorsAndRecovers(t *testing.T) { + c, path := watcherFixture(t) + w := manualWatcher(t, c, path) + w.opts.Debounce = time.Hour + ch := w.subscribe() + if err := os.WriteFile(path, []byte("value="), 0600); err != nil { + t.Fatal(err) + } + w.checkAndReload(c) + select { + case event := <-ch: + t.Fatalf("transient error emitted: %s", event) + default: + } + if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil { + t.Fatal(err) + } + w.checkAndReload(c) + w.observedAt = time.Now().Add(-2 * time.Hour) + w.checkAndReload(c) + if value, _ := c.Get("value"); value != int64(2) { + t.Fatal(value) + } + if event := <-ch; event != "value" { + t.Fatal(event) + } + // A stable invalid edit is reported once and leaves the old value intact. + _ = os.WriteFile(path, []byte("value=256\n"), 0600) + w.checkAndReload(c) + w.observedAt = time.Now().Add(-2 * time.Hour) + w.checkAndReload(c) + w.checkAndReload(c) + select { + case <-ch: + default: + t.Fatal("missing invalid edit event") + } + select { + case event := <-ch: + t.Fatalf("duplicate invalid edit: %s", event) + default: + } + if value, _ := c.Get("value"); value != int64(2) { + t.Fatal("invalid value published") + } +} + +func TestWatcherRetriesTimedOutUnchangedContent(t *testing.T) { + for _, mode := range []string{"read-error", "late-result"} { + t.Run(mode, func(t *testing.T) { + c, path := watcherFixture(t) + w := manualWatcher(t, c, path) + w.opts.ReloadTimeout = 20 * time.Millisecond + ch := w.subscribe() + if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil { + t.Fatal(err) + } + staged, err := readConfigFile(context.Background(), path, fileSettings{}) + if err != nil { + t.Fatal(err) + } + attempts := 0 + w.read = func(ctx context.Context, _ string, _ fileSettings) (*fileSnapshot, error) { + attempts++ + if attempts <= 2 { + <-ctx.Done() + if mode == "read-error" { + return nil, ctx.Err() + } + } + return staged, nil + } + w.checkAndReload(c) + select { + case event := <-ch: + if event != EventReloadTimeout { + t.Fatal(event) + } + default: + t.Fatal("timeout event missing") + } + w.checkAndReload(c) + if value, _ := c.Get("value"); value != int64(1) { + t.Fatal("timed-out result published") + } + select { + case event := <-ch: + t.Fatalf("duplicate timeout: %s", event) + default: + } + w.checkAndReload(c) + if value, _ := c.Get("value"); value != int64(2) { + t.Fatal("unchanged content was not retried after timeout") + } + select { + case event := <-ch: + if event != "value" { + t.Fatal(event) + } + default: + t.Fatal("recovery event missing") + } + }) + } +} + +func TestWatcherSameMetadataReplacementAndRecreation(t *testing.T) { + c, path := watcherFixture(t) + w := manualWatcher(t, c, path) + ch := w.subscribe() + w.checkAndReload(c) + info, _ := os.Stat(path) + replacement := path + ".new" + _ = os.WriteFile(replacement, []byte("value=2\n"), 0600) + _ = os.Chtimes(replacement, info.ModTime(), info.ModTime()) + if err := os.Rename(replacement, path); err != nil { + t.Fatal(err) + } + w.checkAndReload(c) + if value, _ := c.Get("value"); value != int64(2) { + t.Fatal("replacement missed") + } + if event := <-ch; event != "value" { + t.Fatal(event) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + w.checkAndReload(c) + w.checkAndReload(c) + if event := <-ch; event != EventFileDeleted { + t.Fatal(event) + } + select { + case event := <-ch: + t.Fatalf("repeated deletion: %s", event) + default: + } + _ = os.WriteFile(path, []byte("value=3\n"), 0600) + w.checkAndReload(c) + if value, _ := c.Get("value"); value != int64(3) { + t.Fatal("recreation missed") + } + c.StopAutoUpdate() + for range ch { + } // Stop closes every subscriber synchronously. + c.AutoUpdateWithOptions(WatchOptions{PollInterval: time.Hour}) + if !c.IsWatching() { + t.Fatal("restart failed") + } +} + +func TestBuilderDoesNotHideInvalidSourceBehindMissingFile(t *testing.T) { + var target struct { + Port uint8 `toml:"port"` + } + c, err := NewBuilder().WithTarget(&target).WithFile(filepath.Join(t.TempDir(), "missing.toml")).WithArgs([]string{"--port=256"}).Build() + if err == nil || c != nil { + t.Fatalf("missing file hid invalid CLI: %v", err) + } +}