v0.2.2 fixes: split only text sources, report a mode change once, don't block on open
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-03 22:08:43 -04:00
1 parent 2a47be454d
commit 9b4d98672d
19 files changed
+365 -59

No files matched your search

+8 -5
View File
@@ -62,9 +62,12 @@ mutate that caller-owned object.
Float64-to-float32 rounding within range remains supported. Full uint64 is Float64-to-float32 rounding within range remains supported. Full uint64 is
supported in memory; saving a value above MaxInt64 fails without changing the supported in memory; saving a value above MaxInt64 fails without changing the
destination file. Use an application-level string representation if needed. destination file. Use an application-level string representation if needed.
- Strings can represent decimal numbers, booleans, comma-separated slices/arrays, - Strings can represent decimal numbers, booleans, durations, RFC3339
durations, RFC3339 timestamps, IP addresses, CIDR networks and URLs. Boolean to timestamps, IP addresses, CIDR networks and URLs. For a slice or array path,
string conversion uses `true`/`false`. There are no user decode hooks or automatic environment and command-line text splits at commas; a string from a file,
`Set` or `ScanMap` is one element, since those have native arrays and a pattern
may hold a comma. Boolean to string conversion uses `true`/`false`.
Conversion errors name the path, never the value. There are no user decode hooks or automatic
`encoding.TextUnmarshaler` calls. `encoding.TextUnmarshaler` calls.
- Setters and source loads reject values incompatible with registered defaults - Setters and source loads reject values incompatible with registered defaults
immediately. Check their errors. Untyped nil defaults leave the value type open; immediately. Check their errors. Untyped nil defaults leave the value type open;
@@ -94,8 +97,8 @@ for event := range changes {
``` ```
The watcher fingerprints file contents, waits for a quiet debounce interval, then The watcher fingerprints file contents, waits for a quiet debounce interval, then
validates and publishes. Invalid edits, timeouts, deletion and blocked permission validates and publishes. Invalid edits, timeouts, deletion and a permission
changes retain the last valid state. Cancelled, expired or superseded reloads change, reported once before the next edit applies, retain the last valid state. Cancelled, expired or superseded reloads
cannot publish. Stop closes subscriptions synchronously; a new Watch call can cannot publish. Stop closes subscriptions synchronously; a new Watch call can
restart watching. Notifications are buffered and best effort. restart watching. Notifications are buffered and best effort.
+2 -2
View File
@@ -96,8 +96,8 @@ func (b *Builder) Build() (*Config, error) {
// 2. Load configuration // 2. Load configuration
loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts) loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts)
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) { if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
// Return on fatal load errors. ErrConfigNotFound is not fatal. // Return on fatal load errors, labelled by source. ErrConfigNotFound is not fatal.
return nil, wrapError(ErrFileAccess, loadErr) return nil, loadErr
} }
// 3. Run non-typed validators // 3. Run non-typed validators
+3
View File
@@ -259,6 +259,9 @@ func (c *Config) setSourceLocked(source Source, path string, value any) error {
if !validSource(source) { if !validSource(source) {
return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source)) return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source))
} }
if source == SourceEnv || source == SourceCLI {
value = textList(item.defaultValue, value)
}
if err := validateValue(item, value); err != nil { if err := validateValue(item, value); err != nil {
return err return err
} }
+39 -13
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"net" "net"
"net/url" "net/url"
@@ -120,14 +121,14 @@ func decodeInto(data any, dst reflect.Value, depth int) error {
case durationType: case durationType:
v, err := time.ParseDuration(s) v, err := time.ParseDuration(s)
if err != nil { if err != nil {
return err return errors.New("invalid duration: want a number and a unit, as 30s")
} }
dst.SetInt(int64(v)) dst.SetInt(int64(v))
return nil return nil
case timeType: case timeType:
v, err := time.Parse(time.RFC3339Nano, s) v, err := time.Parse(time.RFC3339Nano, s)
if err != nil { if err != nil {
return err return errors.New("invalid time: want RFC 3339")
} }
dst.Set(reflect.ValueOf(v)) dst.Set(reflect.ValueOf(v))
return nil return nil
@@ -137,7 +138,7 @@ func decodeInto(data any, dst reflect.Value, depth int) error {
} }
v := net.ParseIP(s) v := net.ParseIP(s)
if v == nil { if v == nil {
return fmt.Errorf("invalid IP address %q", s) return errors.New("invalid IP address")
} }
dst.Set(reflect.ValueOf(v)) dst.Set(reflect.ValueOf(v))
return nil return nil
@@ -147,7 +148,7 @@ func decodeInto(data any, dst reflect.Value, depth int) error {
} }
_, v, err := net.ParseCIDR(s) _, v, err := net.ParseCIDR(s)
if err != nil { if err != nil {
return fmt.Errorf("invalid CIDR: %w", err) return errors.New("invalid CIDR")
} }
dst.Set(reflect.ValueOf(*v)) dst.Set(reflect.ValueOf(*v))
return nil return nil
@@ -157,7 +158,7 @@ func decodeInto(data any, dst reflect.Value, depth int) error {
} }
v, err := url.Parse(s) v, err := url.Parse(s)
if err != nil { if err != nil {
return fmt.Errorf("invalid URL: %w", err) return errors.New("invalid URL")
} }
dst.Set(reflect.ValueOf(*v)) dst.Set(reflect.ValueOf(*v))
return nil return nil
@@ -166,35 +167,34 @@ func decodeInto(data any, dst reflect.Value, depth int) error {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
v, err := strconv.ParseInt(s, 10, dst.Type().Bits()) v, err := strconv.ParseInt(s, 10, dst.Type().Bits())
if err != nil { if err != nil {
return err return valueless("integer", err)
} }
dst.SetInt(v) dst.SetInt(v)
return nil return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
v, err := strconv.ParseUint(s, 10, dst.Type().Bits()) v, err := strconv.ParseUint(s, 10, dst.Type().Bits())
if err != nil { if err != nil {
return err return valueless("unsigned integer", err)
} }
dst.SetUint(v) dst.SetUint(v)
return nil return nil
case reflect.Float32, reflect.Float64: case reflect.Float32, reflect.Float64:
v, err := strconv.ParseFloat(s, dst.Type().Bits()) v, err := strconv.ParseFloat(s, dst.Type().Bits())
if err != nil { if err != nil {
return err return valueless("float", err)
} }
return toml.Decode(v, dst.Addr().Interface()) return toml.Decode(v, dst.Addr().Interface())
case reflect.Bool: case reflect.Bool:
v, err := strconv.ParseBool(s) v, err := strconv.ParseBool(s)
if err != nil { if err != nil {
return err return valueless("bool", err)
} }
dst.SetBool(v) dst.SetBool(v)
return nil return nil
case reflect.Slice, reflect.Array: case reflect.Slice, reflect.Array:
parts := []string{} // One entry: files and maps have native arrays, and a pattern or
if s != "" { // a DN may hold commas. Text sources split earlier (textList).
parts = strings.Split(s, ",") parts := []string{s}
}
data, src = parts, reflect.ValueOf(parts) data, src = parts, reflect.ValueOf(parts)
} }
} }
@@ -301,6 +301,32 @@ func decodeInto(data any, dst reflect.Value, depth int) error {
return nil return nil
} }
// valueless reports a failed conversion without the input, which may be a
// secret misplaced into a numeric field and would reach logs and events
func valueless(kind string, err error) error {
if ne, ok := errors.AsType[*strconv.NumError](err); ok {
return fmt.Errorf("invalid %s: %w", kind, ne.Err)
}
return fmt.Errorf("invalid %s", kind)
}
// textList splits a string for a list path at commas: environment and
// command-line values are text, where a comma is the only list syntax
func textList(defaultValue, value any) any {
s, ok := value.(string)
t := reflect.TypeOf(defaultValue)
if !ok || t == nil || t == ipType || t.Kind() != reflect.Slice && t.Kind() != reflect.Array || t.Elem().Kind() == reflect.Uint8 {
return value
}
parts := []any{}
if s != "" {
for p := range strings.SplitSeq(s, ",") {
parts = append(parts, p)
}
}
return parts
}
func fieldKey(f reflect.StructField) string { func fieldKey(f reflect.StructField) string {
key, _, _ := strings.Cut(f.Tag.Get(FormatTOML), ",") key, _, _ := strings.Cut(f.Tag.Get(FormatTOML), ",")
if key == "" { if key == "" {
+3 -2
View File
@@ -24,8 +24,9 @@ provided by the caller. A present nil value clears its destination.
Conversions support signed/unsigned numbers, floats, booleans, strings, slices, Conversions support signed/unsigned numbers, floats, booleans, strings, slices,
arrays, string-keyed maps, structs and pointers. Durations, time.Time, net.IP, arrays, string-keyed maps, structs and pointers. Durations, time.Time, net.IP,
net.IPNet and url.URL are atomic configuration values, including pointer forms. net.IPNet and url.URL are atomic configuration values, including pointer forms.
They accept their native types or documented text representations. Comma-separated They accept their native types or documented text representations. Environment and
strings can populate typed numeric slices as well as string slices. command-line text splits at commas into typed numeric slices as well as string
slices; any other string is one element.
No truncating integer conversions or integer-to-float precision loss is allowed. 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 Float narrowing may round within range. NaN/Inf and unsupported object graphs are
+11 -7
View File
@@ -1,14 +1,18 @@
# Command-line arguments # Command-line arguments
`LoadCLI` accepts `--server.port=9000`, `--server.port 9000`, and bare boolean flags `LoadCLI` accepts `--server.port=9000`, `--server.port 9000`, and bare boolean flags
such as `--debug`. Arguments after `--` are positional and are not parsed. Other such as `--debug`. A bare flag is the boolean true, so on a path that is not
positional arguments are ignored. There is no short-flag parser; use Go's flag package boolean (`--dir --debug`) it fails with "--dir needs a value". Arguments after `--`
when that interface is needed. Paths must obey registration's dot-separated key syntax. are positional and are not parsed. 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.
Values remain strings and are validated against registered types before publication. Values remain strings and are validated against registered types before publication;
Each successful call replaces the complete CLI source, including an empty argument a slice or array path splits them at commas. Each successful call replaces the
list. Omitted flags lose their previous overrides. Unknown paths are recorded in complete CLI source, including an empty argument list. Omitted flags lose their
`UnknownCLIKeys` in sorted order and are not registered. The returned list is a copy. previous overrides. Unknown paths are recorded in `UnknownCLIKeys` in sorted order,
followed by the arguments before `--` that are no `--` flag (a positional word, a
`-q`), and are not registered. The returned list is a copy.
`GenerateFlags` creates a flag.FlagSet from defaults. `BindFlags` atomically replaces `GenerateFlags` creates a flag.FlagSet from defaults. `BindFlags` atomically replaces
the CLI source with explicitly visited flags, rejects unregistered flags, and uses the CLI source with explicitly visited flags, rejects unregistered flags, and uses
+4 -2
View File
@@ -8,8 +8,10 @@ Options are copied. Custom transforms run outside Config locks and may inspect C
Environment values are stored as strings and validated against registered types Environment values are stored as strings and validated against registered types
before any part of the replacement is published. Numbers use decimal text; durations 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 Go duration syntax; timestamps use RFC3339Nano-compatible text. Slices and arrays
use comma-separated elements without an escaping convention. Empty text gives an use comma-separated elements without an escaping convention. An empty variable is
empty slice. Values larger than MaxValueSize are rejected. unset, so a template may leave one blank. Two paths whose names map to one set
variable (`a.b_c` and `a_b.c` both to `APP_A_B_C`) fail the load. Values larger than
MaxValueSize are rejected.
`RegisterWithEnv` and a struct field's `env` tag load an explicitly named variable `RegisterWithEnv` and a struct field's `env` tag load an explicitly named variable
during registration. Later `LoadEnv` calls use the configured transform/whitelist, during registration. Later `LoadEnv` calls use the configured transform/whitelist,
+7 -5
View File
@@ -9,11 +9,13 @@ removes that source override, exposing the next source or registered default.
Loads parse and validate before publication. Syntax errors, incompatible values, Loads parse and validate before publication. Syntax errors, incompatible values,
overflow and read failures leave prior values, comments and the loaded path intact. 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 Readers must be regular files, checked on the opened descriptor. The open does not
on Unix and enforce size limits both before and during reading. Ownership checks block, so a FIFO swapped in after the path's check fails at once, and a file whose
fail explicitly on unsupported platforms. PreventPathTraversal rejects relative reads would block fails its read. Security options check ownership of the opened
paths escaping through `..`; it is not a symlink sandbox. Absolute paths remain file on Unix and enforce size limits both before and during reading. Ownership
allowed. A zero MaxFileSize means no configured byte limit. checks fail explicitly on unsupported platforms. PreventPathTraversal checks the
path text only: it rejects relative paths escaping through `..`, not symlinks or
absolute paths. A zero MaxFileSize means no configured byte limit.
`Save(path)` writes effective values; `SaveSource(path, source)` writes one source. `Save(path)` writes effective values; `SaveSource(path, source)` writes one source.
Default selects registered defaults. Both use a temporary file in the destination Default selects registered defaults. Both use a temporary file in the destination
+5 -3
View File
@@ -21,9 +21,11 @@ timeouts for the same observation emit only one notification until recovery.
Events are changed registered paths, or `file_deleted`, `permissions_changed`, Events are changed registered paths, or `file_deleted`, `permissions_changed`,
`reload_timeout`, `reload_error:<detail>`, and `precedence:<path>`. A path event means `reload_timeout`, `reload_error:<detail>`, and `precedence:<path>`. A path event means
the effective value changed; a file value masked by CLI need not notify. Group/world the effective value changed; a file value masked by CLI need not notify. With
permission changes are blocked when VerifyPermissions is enabled; correcting the VerifyPermissions (on by default), a change of the group/world permission bits is
permissions allows loading again. Default options enable that check. reported once as `permissions_changed` and that version is not applied; the next
change applies under the new mode. While a watcher runs, a further Watch or
WatchWithOptions call subscribes to it and its options are ignored.
Notifications are buffered (10 entries) and best effort; a slow subscriber may miss 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. events and should read the latest full state. MaxWatchers bounds retained channels.
+8 -1
View File
@@ -68,7 +68,7 @@ func readConfigFile(ctx context.Context, path string, opts fileSettings) (*fileS
if !info.Mode().IsRegular() { if !info.Mode().IsRegular() {
return nil, wrapError(ErrFileAccess, fmt.Errorf("config must be a regular file")) return nil, wrapError(ErrFileAccess, fmt.Errorf("config must be a regular file"))
} }
file, err := os.Open(path) file, err := openConfig(path)
if err != nil { if err != nil {
return nil, wrapError(ErrFileAccess, err) return nil, wrapError(ErrFileAccess, err)
} }
@@ -113,6 +113,13 @@ func readConfigFile(ctx context.Context, path string, opts fileSettings) (*fileS
return &fileSnapshot{data, after, sha256.Sum256(data)}, nil return &fileSnapshot{data, after, sha256.Sum256(data)}, nil
} }
// openConfig opens without blocking: a FIFO swapped in after the Stat cannot
// hang open(2), and a regular-looking file whose reads block (/proc/kmsg)
// fails its read instead
func openConfig(path string) (*os.File, error) {
return os.OpenFile(path, os.O_RDONLY|openNonBlock, 0)
}
func parseFile(snapshot *fileSnapshot) (map[string]any, []string, error) { func parseFile(snapshot *fileSnapshot) (map[string]any, []string, error) {
root, err := toml.NewParser(snapshot.data).Parse() root, err := toml.NewParser(snapshot.data).Parse()
if err != nil { if err != nil {
+30
View File
@@ -0,0 +1,30 @@
//go:build unix
package config
import (
"path/filepath"
"syscall"
"testing"
"time"
)
// The open cannot block: a FIFO swapped in after the Stat fails at once
func TestOpenConfigNeverBlocks(t *testing.T) {
fifo := filepath.Join(t.TempDir(), "config.toml")
if err := syscall.Mkfifo(fifo, 0600); err != nil {
t.Skip(err)
}
done := make(chan struct{})
go func() {
if f, err := openConfig(fifo); err == nil {
f.Close()
}
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("open blocked on a FIFO without a writer")
}
}
+61
View File
@@ -0,0 +1,61 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
type fuzzTarget struct {
Name string `toml:"name"`
Port int64 `toml:"port"`
Ratio float64 `toml:"ratio"`
On bool `toml:"on"`
Tags []string `toml:"tags"`
IDs []uint16 `toml:"ids"`
Meta map[string]string `toml:"meta"`
Sub struct {
Level string `toml:"level"`
} `toml:"sub"`
}
// Arguments never panic the parser, and what a load accepts decodes
func FuzzLoadCLI(f *testing.F) {
for _, s := range []string{"--name=x", "--port\x009000", "--on\x00--tags=a,b", "-q\x00x\x00--\x00--name", "--sub.level\x00--on", "--ids=1,65536"} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, s string) {
cfg, err := NewBuilder().WithTarget(&fuzzTarget{}).WithArgs(strings.Split(s, "\x00")).Build()
if err != nil {
return
}
if _, err := cfg.AsStruct(); err != nil {
t.Fatalf("accepted %q, then: %v", s, err)
}
})
}
// File contents never panic the loader, and what LoadFile accepts decodes
func FuzzLoadFile(f *testing.F) {
for _, s := range []string{"name = \"x\"\nport = 1", "tags = \"a,b\"\n[sub]\nlevel = \"debug\"", "ids = [1, 2]\n[meta]\nk = \"v\"", "on = true # c\r\n"} {
f.Add([]byte(s))
}
// One file and one Config per worker: each LoadFile replaces the file source
path := filepath.Join(f.TempDir(), "c.toml")
cfg, err := NewBuilder().WithTarget(&fuzzTarget{}).Build()
if err != nil {
f.Fatal(err)
}
f.Fuzz(func(t *testing.T, data []byte) {
if err := os.WriteFile(path, data, 0600); err != nil {
t.Fatal(err)
}
if cfg.LoadFile(path) != nil {
return
}
if _, err := cfg.AsStruct(); err != nil {
t.Fatalf("accepted %q, then: %v", data, err)
}
})
}
+100 -1
View File
@@ -39,7 +39,7 @@ func TestCheckedConversionsAndRollback(t *testing.T) {
var array struct { var array struct {
Values [2]uint16 `toml:"values"` Values [2]uint16 `toml:"values"`
} }
if err := ScanMap(map[string]any{"values": "1,65535"}, &array); err != nil || array.Values != [2]uint16{1, 65535} { if err := ScanMap(map[string]any{"values": []any{"1", "65535"}}, &array); err != nil || array.Values != [2]uint16{1, 65535} {
t.Fatalf("array: %+v %v", array, err) t.Fatalf("array: %+v %v", array, err)
} }
} }
@@ -301,3 +301,102 @@ func TestRegistrationAndRequiredContracts(t *testing.T) {
} }
} }
} }
// A string from a file or a map is one list entry, as a pattern or a DN may
// hold commas; environment and command-line text splits at commas
func TestOnlyTextSourcesSplitLists(t *testing.T) {
type C struct {
Patterns []string `toml:"patterns"`
Hosts []string `toml:"hosts"`
Tags []string `toml:"tags"`
}
path := filepath.Join(t.TempDir(), "c.toml")
writeTestFile(t, path, []byte(`patterns = "password=\\S{8,64}"`+"\n"), 0600)
t.Setenv("ZZ_HOSTS", "a,b")
cfg, err := NewBuilder().WithTarget(&C{}).WithEnvPrefix("ZZ_").WithArgs([]string{"--tags=x,y"}).WithFile(path).Build()
if err != nil {
t.Fatal(err)
}
v, err := cfg.AsStruct()
got := v.(*C)
if err != nil || !reflect.DeepEqual(got.Patterns, []string{`password=\S{8,64}`}) ||
!reflect.DeepEqual(got.Hosts, []string{"a", "b"}) || !reflect.DeepEqual(got.Tags, []string{"x", "y"}) {
t.Fatalf("%+v %v", got, err)
}
var m C
if err := ScanMap(map[string]any{"patterns": "m,n"}, &m); err != nil || !reflect.DeepEqual(m.Patterns, []string{"m,n"}) {
t.Fatalf("ScanMap: %+v %v", m, err)
}
}
// A failed conversion names the field, never its value: a secret misplaced
// into a numeric field would reach logs and watch events
func TestConversionErrorsOmitValues(t *testing.T) {
type C struct {
Port int64 `toml:"port"`
Ratio float64 `toml:"ratio"`
On bool `toml:"on"`
Timeout time.Duration `toml:"timeout"`
}
for _, name := range []string{"ZZ_PORT", "ZZ_RATIO", "ZZ_ON", "ZZ_TIMEOUT"} {
t.Run(name, func(t *testing.T) {
t.Setenv(name, "hunter2-secret")
_, err := NewBuilder().WithTarget(&C{}).WithEnvPrefix("ZZ_").Build()
if err == nil || strings.Contains(err.Error(), "hunter2") {
t.Fatalf("err = %v", err)
}
})
}
}
// Arguments that are no --flag are reported, not dropped, and a bare flag
// sets only a bool: "--dir --quiet" must not make dir "true"
func TestCommandLineReportsStrayArgsAndBareFlags(t *testing.T) {
type C struct {
Dir string `toml:"dir"`
Quiet bool `toml:"quiet"`
}
cfg, err := NewBuilder().WithTarget(&C{}).WithArgs([]string{"input.log", "-q", "--quiet", "--", "after"}).Build()
if err != nil || !reflect.DeepEqual(cfg.UnknownCLIKeys(), []string{"input.log", "-q"}) {
t.Fatalf("stray: %v %v", cfg.UnknownCLIKeys(), err)
}
if _, err := NewBuilder().WithTarget(&C{}).WithArgs([]string{"--dir", "--quiet"}).Build(); err == nil || !strings.Contains(err.Error(), "--dir needs a value") {
t.Fatalf("bare flag on a string path: %v", err)
}
}
// An empty variable is unset, and two paths cannot share one variable name
func TestEnvEmptyIsUnsetAndNamesAreUnique(t *testing.T) {
type C struct {
Port int64 `toml:"port"`
Tags []string `toml:"tags"`
}
t.Setenv("ZZ_PORT", "")
t.Setenv("ZZ_TAGS", "")
cfg, err := NewBuilder().WithTarget(&C{Port: 80, Tags: []string{"a"}}).WithEnvPrefix("ZZ_").Build()
if err != nil {
t.Fatal(err)
}
if v, _ := cfg.AsStruct(); v.(*C).Port != 80 || !reflect.DeepEqual(v.(*C).Tags, []string{"a"}) {
t.Fatalf("empty variables changed values: %+v", v)
}
c := New()
c.Register("a.b_c", "")
c.Register("a_b.c", "")
t.Setenv("ZZ_A_B_C", "x")
if err := c.LoadEnv("ZZ_"); err == nil || !strings.Contains(err.Error(), "would set both") {
t.Fatalf("colliding names: %v", err)
}
}
// Build reports a load error under its source, not as file access
func TestBuildLabelsErrorsBySource(t *testing.T) {
type C struct {
Port int64 `toml:"port"`
}
t.Setenv("ZZ_PORT", "x")
_, err := NewBuilder().WithTarget(&C{}).WithEnvPrefix("ZZ_").Build()
if !errors.Is(err, ErrEnvParse) || errors.Is(err, ErrFileAccess) {
t.Fatalf("err = %v", err)
}
}
+36 -12
View File
@@ -201,11 +201,18 @@ func (c *Config) loadEnv(opts LoadOptions) error {
} }
c.mutex.RUnlock() c.mutex.RUnlock()
values := make(map[string]any) values := make(map[string]any)
byVar := make(map[string]string, len(paths))
for _, path := range paths { for _, path := range paths {
if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] { if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] {
continue continue
} }
if value, ok := os.LookupEnv(transform(path)); ok { name := transform(path)
// Empty counts as unset, so a template may leave a variable blank
if value, ok := os.LookupEnv(name); ok && value != "" {
if other, dup := byVar[name]; dup {
return fmt.Errorf("%s would set both %s and %s", name, other, path)
}
byVar[name] = path
values[path] = value values[path] = value
} }
} }
@@ -215,13 +222,17 @@ func (c *Config) loadEnv(opts LoadOptions) error {
} }
func (c *Config) loadCLI(args []string) error { func (c *Config) loadCLI(args []string) error {
parsed, err := parseArgs(args) parsed, stray, err := parseArgs(args)
if err != nil { if err != nil {
return err return err
} }
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
return c.replaceSourceLocked(SourceCLI, flattenMap(parsed, "")) if err := c.replaceSourceLocked(SourceCLI, flattenMap(parsed, "")); err != nil {
return err
}
c.unknownCLIKeys = append(c.unknownCLIKeys, stray...)
return nil
} }
// Validate the entire replacement before any mutation. Caller holds c.mutex. // Validate the entire replacement before any mutation. Caller holds c.mutex.
@@ -236,6 +247,12 @@ func (c *Config) replaceSourceLocked(source Source, values map[string]any, guard
} }
continue continue
} }
if source == SourceEnv || source == SourceCLI {
value = textList(item.defaultValue, value)
}
if t := reflect.TypeOf(item.defaultValue); source == SourceCLI && value == true && t != nil && t.Kind() != reflect.Bool {
return fmt.Errorf("--%s needs a value", path)
}
if err := validateValue(item, value); err != nil { if err := validateValue(item, value); err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
@@ -304,14 +321,16 @@ func parseValue(s string) any {
return s return s
} }
// parseArgs processes command-line arguments into a nested map structure. // parseArgs processes command-line arguments into a nested map structure. A
func parseArgs(args []string) (map[string]any, error) { // bare flag is the bool true, which only a bool path accepts; arguments that
result := make(map[string]any) // are no --flag before "--" come back as stray, for UnknownCLIKeys.
func parseArgs(args []string) (result map[string]any, stray []string, err error) {
result = make(map[string]any)
i := 0 i := 0
for i < len(args) { for i < len(args) {
arg := args[i] arg := args[i]
if !strings.HasPrefix(arg, "--") { if !strings.HasPrefix(arg, "--") {
// Skip non-flag arguments stray = append(stray, arg)
i++ i++
continue continue
} }
@@ -324,6 +343,7 @@ func parseArgs(args []string) (map[string]any, error) {
var keyPath string var keyPath string
var valueStr string var valueStr string
bare := false
// Check for "--key=value" format // Check for "--key=value" format
if strings.Contains(argContent, "=") { if strings.Contains(argContent, "=") {
@@ -336,7 +356,7 @@ func parseArgs(args []string) (map[string]any, error) {
keyPath = argContent keyPath = argContent
// Check if it's a boolean flag (next arg is another flag or end of args) // Check if it's a boolean flag (next arg is another flag or end of args)
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") {
valueStr = "true" bare = true
i++ // Consume only the flag argument i++ // Consume only the flag argument
} else { } else {
// It's a key-value pair with a space // It's a key-value pair with a space
@@ -354,16 +374,20 @@ func parseArgs(args []string) (map[string]any, error) {
segments := strings.Split(keyPath, ".") segments := strings.Split(keyPath, ".")
for _, segment := range segments { for _, segment := range segments {
if !isValidKeySegment(segment) { if !isValidKeySegment(segment) {
return nil, wrapError(ErrInvalidPath, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath)) return nil, nil, wrapError(ErrInvalidPath, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath))
} }
} }
// Always store as a string. Let Scan handle final type conversion. // Always store as a string. Let Scan handle final type conversion.
if len(valueStr) > MaxValueSize { if len(valueStr) > MaxValueSize {
return nil, ErrValueSize return nil, nil, ErrValueSize
}
if bare {
setNestedValue(result, keyPath, true)
} else {
setNestedValue(result, keyPath, valueStr)
} }
setNestedValue(result, keyPath, valueStr)
} }
return result, nil return result, stray, nil
} }
+7 -6
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
) )
@@ -229,7 +230,7 @@ func TestCLIParsing(t *testing.T) {
name: "BooleanFlags", name: "BooleanFlags",
args: []string{"--enable.debug", "--disable.cache", "false"}, args: []string{"--enable.debug", "--disable.cache", "false"},
expected: map[string]any{ expected: map[string]any{
"enable.debug": "true", "enable.debug": true,
"disable.cache": "false", "disable.cache": "false",
}, },
}, },
@@ -244,7 +245,7 @@ func TestCLIParsing(t *testing.T) {
expected: map[string]any{ expected: map[string]any{
"server.host": "localhost", "server.host": "localhost",
"server.port": "8080", "server.port": "8080",
"enable.tls": "true", "enable.tls": true,
"database.pool.size": "10", "database.pool.size": "10",
}, },
}, },
@@ -261,10 +262,10 @@ func TestCLIParsing(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfg := New() cfg := New()
// Register expected paths // Register expected paths with their type: a bare flag needs a bool path
for path := range tt.expected { for path, want := range tt.expected {
if path != "" { // Skip empty path if path != "" { // Skip empty path
cfg.Register(path, "") cfg.Register(path, reflect.Zero(reflect.TypeOf(want)).Interface())
} }
} }
@@ -285,7 +286,7 @@ func TestCLIParsing(t *testing.T) {
} }
t.Run("InvalidKeySegment", func(t *testing.T) { t.Run("InvalidKeySegment", func(t *testing.T) {
result, err := parseArgs([]string{"--invalid!key=value"}) result, _, err := parseArgs([]string{"--invalid!key=value"})
checkErrorContains(t, err, "invalid command-line key segment") checkErrorContains(t, err, "invalid command-line key segment")
if got := result; got != nil { if got := result; got != nil {
t.Errorf("got %v, want nil", got) t.Errorf("got %v, want nil", got)
+2
View File
@@ -7,6 +7,8 @@ import (
"os" "os"
) )
const openNonBlock = 0
func checkFileOwner(os.FileInfo) error { func checkFileOwner(os.FileInfo) error {
return wrapError(ErrFileAccess, fmt.Errorf("file ownership checks are unsupported on this platform")) return wrapError(ErrFileAccess, fmt.Errorf("file ownership checks are unsupported on this platform"))
} }
+2
View File
@@ -8,6 +8,8 @@ import (
"syscall" "syscall"
) )
const openNonBlock = syscall.O_NONBLOCK
func checkFileOwner(info os.FileInfo) error { func checkFileOwner(info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t) stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != uint32(os.Geteuid()) { if !ok || stat.Uid != uint32(os.Geteuid()) {
+4
View File
@@ -197,6 +197,10 @@ func (w *watcher) checkAndReload(c *Config) {
} else { } else {
w.notifyWatchers(event) w.notifyWatchers(event)
w.handled = key w.handled = key
if event == EventPermissionsChanged {
// Reported once; the next change applies under the new mode
w.lastMode = snapshot.info.Mode()
}
} }
return return
} }
+33
View File
@@ -256,3 +256,36 @@ func TestBuilderDoesNotHideInvalidSourceBehindMissingFile(t *testing.T) {
t.Fatalf("missing file hid invalid CLI: %v", err) t.Fatalf("missing file hid invalid CLI: %v", err)
} }
} }
// A mode change is reported once; the next edit applies under the new mode
func TestWatcherReportsPermissionChangeOnce(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
w.opts.VerifyPermissions = true
ch := w.subscribe()
for i, step := range []struct {
edit func() error
event string
value int64
}{
{func() error { return os.WriteFile(path, []byte("value=2\n"), 0600) }, "value", 2},
{func() error { return os.Chmod(path, 0640) }, EventPermissionsChanged, 2},
{func() error { return os.WriteFile(path, []byte("value=3\n"), 0640) }, "value", 3},
} {
if err := step.edit(); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
select {
case event := <-ch:
if event != step.event {
t.Fatalf("step %d: event %s, want %s", i, event, step.event)
}
default:
t.Fatalf("step %d: no event", i)
}
if value, _ := c.Get("value"); value != step.value {
t.Fatalf("step %d: value %v, want %d", i, value, step.value)
}
}
}