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 <nil> is not a map of metrics settings' rendering for
metrics: null with the null-specific message.
This commit is contained in:
2026-08-07 17:02:16 +00:00
parent 370545997f
commit c0d325156f

View File

@@ -132,10 +132,12 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
// validateKnownKeys rejects configuration files containing keys the // validateKnownKeys rejects configuration files containing keys the
// application does not understand, so typos fail at startup instead of // 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. // smartconfig consumes it for environment variable injection.
func validateKnownKeys(sc *smartconfig.Config) error { func validateKnownKeys(sc *smartconfig.Config) error {
var unknown []string var unknown, nullKeys []string
for key, value := range sc.Data() { for key, value := range sc.Data() {
if !isKnownConfigKey(key) { if !isKnownConfigKey(key) {
@@ -144,6 +146,12 @@ func validateKnownKeys(sc *smartconfig.Config) error {
continue continue
} }
if value == nil {
nullKeys = append(nullKeys, key)
continue
}
if key == "metrics" { if key == "metrics" {
metricsMap, ok := value.(map[string]interface{}) metricsMap, ok := value.(map[string]interface{})
if !ok { if !ok {
@@ -152,9 +160,15 @@ func validateKnownKeys(sc *smartconfig.Config) error {
"metrics", value) "metrics", value)
} }
for subkey := range metricsMap { for subkey, subvalue := range metricsMap {
if subkey != "username" && subkey != "password" { if subkey != "username" && subkey != "password" {
unknown = append(unknown, "metrics."+subkey) 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, ", ")) 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 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 // isKnownConfigKey reports whether key is a permitted top-level
// configuration key. // configuration key.
func isKnownConfigKey(key string) bool { 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 // 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) { func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
if sc == nil { if sc == nil {
return defaultVal, nil return defaultVal, nil
} }
raw, ok := sc.Get(key) raw, ok := sc.Get(key)
if !ok || raw == nil { if !ok {
return defaultVal, nil return defaultVal, nil
} }
if raw == nil {
return "", errNullConfigValue(key)
}
str, ok := raw.(string) str, ok := raw.(string)
if !ok { if !ok {
return "", fmt.Errorf("config key %q: value %v (%T) is not a string", 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 // 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; // omitted. A present value that is not a whole number, or is explicitly
// fractional values are never truncated. // null, is an error; fractional values are never truncated.
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) { func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
if sc == nil { if sc == nil {
return defaultVal, nil return defaultVal, nil
} }
raw, ok := sc.Get(key) raw, ok := sc.Get(key)
if !ok || raw == nil { if !ok {
return defaultVal, nil return defaultVal, nil
} }
if raw == nil {
return 0, errNullConfigValue(key)
}
switch val := raw.(type) { switch val := raw.(type) {
case int: case int:
return val, nil 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 // 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 // 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) { func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
if sc == nil { if sc == nil {
return defaultVal, nil return defaultVal, nil
} }
raw, ok := sc.Get(key) raw, ok := sc.Get(key)
if !ok || raw == nil { if !ok {
return defaultVal, nil return defaultVal, nil
} }
if raw == nil {
return false, errNullConfigValue(key)
}
switch val := raw.(type) { switch val := raw.(type) {
case bool: case bool:
return val, nil 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 // validateAllowlistHostsValue checks the raw shape of the
// allowlist_hosts value before the lenient extraction in getStringSlice // allowlist_hosts value before the lenient extraction in getStringSlice
// runs: a value that is not a list of strings (or a comma-separated // runs: an explicitly null value, a value that is not a list of strings
// string), a non-string entry, or an empty entry is an error, never // (or a comma-separated string), a non-string entry, or an empty entry
// silently skipped. // is an error, never silently skipped.
func validateAllowlistHostsValue(sc *smartconfig.Config) error { func validateAllowlistHostsValue(sc *smartconfig.Config) error {
const key = "allowlist_hosts" const key = "allowlist_hosts"
raw, ok := sc.Get(key) raw, ok := sc.Get(key)
if !ok || raw == nil { if !ok {
return nil return nil
} }
if raw == nil {
return errNullConfigValue(key)
}
switch val := raw.(type) { switch val := raw.(type) {
case []interface{}: case []interface{}:
for _, item := range val { for _, item := range val {