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 {