Refuse config keys that no setting has (closes #419)
check / check (push) Failing after 21m21s

config.Parse walks the file as written and fails on the first key that
matches no setting, naming its dotted path and line, so a typo in
exclude or prune_grace_period no longer falls back silently. ${...}
references and YAML merge keys still load. config set refuses such a key
before writing. The key-to-field lookup moves from internal/cli to
config.SettingType so both use it.

Model: opus-5-5
This commit is contained in:
sneak committed 2026-11-11 00:26:47 +00:00
1 parent aae7fa17ae
commit ba343c30e2
6 files changed
+283 -47

No files matched your search

+149
View File
@@ -0,0 +1,149 @@
package config
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// yamlMergeTag is the tag YAML gives a merge key (<<), which adds the keys
// of the map it names to the map it sits in.
const yamlMergeTag = "!!merge"
var errUnknownKey = errors.New("unknown config key")
// SettingType returns the type of the setting that the dotted key names in
// Config, following the fields' yaml tags, as for s3.bucket or
// snapshots.home.exclude.0. A snapshot name or a list index may be any
// word. It returns false when no setting has that key.
func SettingType(keys []string) (reflect.Type, bool) {
typ := reflect.TypeFor[Config]()
for _, key := range keys {
switch {
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
typ = typ.Elem()
case typ.Kind() == reflect.Struct:
field, ok := yamlField(typ, key)
if !ok {
return nil, false
}
typ = field.Type
default:
return nil, false
}
}
return typ, true
}
// yamlField returns the field of struct type typ whose yaml tag names key.
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
for field := range typ.Fields() {
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
if name == key && name != "-" {
return field, true
}
}
return reflect.StructField{}, false
}
// checkKeys returns an error naming the first key in node, a parsed config
// file or part of one at the dotted path, that no setting in typ has. The
// error gives the key's line in the file. A value of the wrong type is left
// for the YAML decoder to report.
func checkKeys(node *yaml.Node, typ reflect.Type, path string) error {
switch node.Kind {
case yaml.DocumentNode:
for _, child := range node.Content {
err := checkKeys(child, typ, path)
if err != nil {
return err
}
}
case yaml.AliasNode:
return checkKeys(node.Alias, typ, path)
case yaml.SequenceNode:
if typ.Kind() != reflect.Slice {
return nil
}
for i, child := range node.Content {
err := checkKeys(child, typ.Elem(), joinKey(path, strconv.Itoa(i)))
if err != nil {
return err
}
}
case yaml.MappingNode:
return checkMappingKeys(node, typ, path)
case yaml.ScalarNode:
// A scalar has no keys.
}
return nil
}
// checkMappingKeys is checkKeys for a map in the file.
func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string) error {
for i := 0; i+1 < len(node.Content); i += 2 {
key, value := node.Content[i], node.Content[i+1]
if key.Tag == yamlMergeTag {
// The value is one map or a list of maps.
merged := []*yaml.Node{value}
if value.Kind == yaml.SequenceNode {
merged = value.Content
}
for _, m := range merged {
err := checkKeys(m, typ, path)
if err != nil {
return err
}
}
continue
}
keyPath := joinKey(path, key.Value)
var valueType reflect.Type
switch {
case typ.Kind() == reflect.Map:
valueType = typ.Elem()
case typ.Kind() == reflect.Struct:
field, ok := yamlField(typ, key.Value)
if !ok {
return fmt.Errorf("%w %q on line %d",
errUnknownKey, keyPath, key.Line)
}
valueType = field.Type
default:
return nil
}
err := checkKeys(value, valueType, keyPath)
if err != nil {
return err
}
}
return nil
}
// joinKey appends key to the dotted path.
func joinKey(path, key string) string {
if path == "" {
return key
}
return path + "." + key
}