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" // EnvSection is the top-level key of a map of environment variables that // smartconfig sets while it loads the config. It is not a vaultik setting, // so its keys are not checked. const EnvSection = "env" var ( errUnknownKey = errors.New("unknown config key") errAnchorContainsItself = errors.New("anchor contains itself") ) // nodeAndType is a node of a parsed config and the setting type it is // checked against. type nodeAndType struct { node *yaml.Node typ reflect.Type } // 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 an unknown key error when no setting has that key. func SettingType(keys []string) (reflect.Type, error) { 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, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, ".")) } typ = field.Type default: return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, ".")) } } return typ, nil } // 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 // or part of one at the dotted path, that no setting in typ has. The error // gives the key's line in the file, when it has one. A value of the wrong // type is left for the YAML decoder to report. following holds the aliases // the walk is inside. Meeting one of them again means its anchor contains // itself, which is an error. checked holds each anchored node the walk has // checked completely, with the type it was checked against. An alias to // that node is not followed again for that type. func checkKeys(node *yaml.Node, typ reflect.Type, path string, following map[*yaml.Node]bool, checked map[nodeAndType]bool, ) error { switch node.Kind { case yaml.DocumentNode: for _, child := range node.Content { err := checkKeys(child, typ, path, following, checked) if err != nil { return err } } case yaml.AliasNode: if following[node] { return fmt.Errorf("%w: %q on line %d", errAnchorContainsItself, node.Value, node.Line) } target := nodeAndType{node.Alias, typ} if checked[target] { return nil } following[node] = true defer delete(following, node) err := checkKeys(node.Alias, typ, path, following, checked) if err != nil { return err } checked[target] = true 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)), following, checked) if err != nil { return err } } case yaml.MappingNode: return checkMappingKeys(node, typ, path, following, checked) case yaml.ScalarNode: // A scalar has no keys. } return nil } // checkFilledKeys is checkKeys for data, the config after smartconfig has // filled in its ${...} references. A key that a reference brings in is not // in the file, and the copy that Encode makes has no line numbers. func checkFilledKeys(data map[string]any) error { var filled yaml.Node err := filled.Encode(data) if err != nil { return fmt.Errorf("failed to marshal config data: %w", err) } return checkKeys(&filled, reflect.TypeFor[Config](), "", map[*yaml.Node]bool{}, map[nodeAndType]bool{}) } // checkMappingKeys is checkKeys for a map. func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string, following map[*yaml.Node]bool, checked map[nodeAndType]bool, ) 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, following, checked) if err != nil { return err } } continue } keyPath := joinKey(path, key.Value) if keyPath == EnvSection { continue } 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 && key.Line == 0 { // A key that a ${...} reference brought in has no line. return fmt.Errorf("%w %q", errUnknownKey, keyPath) } 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, following, checked) 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 }