From c0d325156f091965664258d44341d4ef6a86ec4d Mon Sep 17 00:00:00 2001 From: sneak Date: Fri, 7 Aug 2026 17:02:16 +0000 Subject: [PATCH] fix: abort startup when a config key is explicitly set to null An explicitly-null key (port: null, bare port:, debug: ~, metrics subkeys, and every other known key) previously fell through the ok/nil check in the strict getters and silently took the default, violating the no-silent-fallback rule and contradicting metrics: null which already aborted. validateKnownKeys now collects null-valued keys (top level and metrics subkeys) and aborts naming each one, and the strict getters and validateAllowlistHostsValue error on null instead of defaulting as defense in depth. This also replaces the unhelpful 'value is not a map of metrics settings' rendering for metrics: null with the null-specific message. --- internal/config/config.go | 80 ++++++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 6a3af39..269ec8e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -132,10 +132,12 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) { // validateKnownKeys rejects configuration files containing keys the // application does not understand, so typos fail at startup instead of -// being silently ignored. The env section is permitted because +// being silently ignored, and rejects keys that are explicitly set to +// null: a null is a SET value, never an omission, so it must not +// silently take the default. The env section is permitted because // smartconfig consumes it for environment variable injection. func validateKnownKeys(sc *smartconfig.Config) error { - var unknown []string + var unknown, nullKeys []string for key, value := range sc.Data() { if !isKnownConfigKey(key) { @@ -144,6 +146,12 @@ func validateKnownKeys(sc *smartconfig.Config) error { continue } + if value == nil { + nullKeys = append(nullKeys, key) + + continue + } + if key == "metrics" { metricsMap, ok := value.(map[string]interface{}) if !ok { @@ -152,9 +160,15 @@ func validateKnownKeys(sc *smartconfig.Config) error { "metrics", value) } - for subkey := range metricsMap { + for subkey, subvalue := range metricsMap { if subkey != "username" && subkey != "password" { unknown = append(unknown, "metrics."+subkey) + + continue + } + + if subvalue == nil { + nullKeys = append(nullKeys, "metrics."+subkey) } } } @@ -166,9 +180,29 @@ func validateKnownKeys(sc *smartconfig.Config) error { return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", ")) } + if len(nullKeys) > 0 { + sort.Strings(nullKeys) + + if len(nullKeys) == 1 { + return errNullConfigValue(nullKeys[0]) + } + + return fmt.Errorf( + "config keys %s: value is null; omit a key entirely to use its default", + strings.Join(nullKeys, ", ")) + } + return nil } +// errNullConfigValue reports a config key that is explicitly set to +// null (including the bare "key:" form and the "~" alias). Silently +// applying the default would mask a truncated or typo'd config entry. +func errNullConfigValue(key string) error { + return fmt.Errorf( + "config key %q: value is null; omit the key entirely to use the default", key) +} + // isKnownConfigKey reports whether key is a permitted top-level // configuration key. func isKnownConfigKey(key string) bool { @@ -371,17 +405,22 @@ func (l *strictLoader) boolVal(key string, defaultVal bool) bool { } // getString returns the string value for key, or defaultVal if the key -// is omitted. A present value that is not a string is an error. +// is omitted. A present value that is not a string, or is explicitly +// null, is an error. func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) { if sc == nil { return defaultVal, nil } raw, ok := sc.Get(key) - if !ok || raw == nil { + if !ok { return defaultVal, nil } + if raw == nil { + return "", errNullConfigValue(key) + } + str, ok := raw.(string) if !ok { return "", fmt.Errorf("config key %q: value %v (%T) is not a string", @@ -392,18 +431,22 @@ func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) { } // getInt returns the integer value for key, or defaultVal if the key is -// omitted. A present value that is not a whole number is an error; -// fractional values are never truncated. +// omitted. A present value that is not a whole number, or is explicitly +// null, is an error; fractional values are never truncated. func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) { if sc == nil { return defaultVal, nil } raw, ok := sc.Get(key) - if !ok || raw == nil { + if !ok { return defaultVal, nil } + if raw == nil { + return 0, errNullConfigValue(key) + } + switch val := raw.(type) { case int: return val, nil @@ -430,17 +473,22 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) { // getBool returns the boolean value for key, or defaultVal if the key // is omitted. A present value that is not a boolean (or a ParseBool-able -// string) is an error; numbers are not accepted as booleans. +// string), or is explicitly null, is an error; numbers are not accepted +// as booleans. func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) { if sc == nil { return defaultVal, nil } raw, ok := sc.Get(key) - if !ok || raw == nil { + if !ok { return defaultVal, nil } + if raw == nil { + return false, errNullConfigValue(key) + } + switch val := raw.(type) { case bool: return val, nil @@ -459,17 +507,21 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) // validateAllowlistHostsValue checks the raw shape of the // allowlist_hosts value before the lenient extraction in getStringSlice -// runs: a value that is not a list of strings (or a comma-separated -// string), a non-string entry, or an empty entry is an error, never -// silently skipped. +// runs: an explicitly null value, a value that is not a list of strings +// (or a comma-separated string), a non-string entry, or an empty entry +// is an error, never silently skipped. func validateAllowlistHostsValue(sc *smartconfig.Config) error { const key = "allowlist_hosts" raw, ok := sc.Get(key) - if !ok || raw == nil { + if !ok { return nil } + if raw == nil { + return errNullConfigValue(key) + } + switch val := raw.(type) { case []interface{}: for _, item := range val {