1 Commits
Author SHA1 Message Date
sneak 3a74896114 Refuse config keys that no setting has (closes #419)
check / check (push) Failing after 19m39s
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. Keys that a
${...} reference brings in are checked once smartconfig has filled it
in, and named without a line. YAML merge keys and a top-level env
section, which smartconfig reads, 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
2026-11-11 01:23:51 +00:00
6 changed files with 1231 additions and 64 deletions

No files matched your search

+1205 -3
View File
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -46,6 +46,7 @@ var (
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
errIndexOutOfRange = errors.New("index out of range")
errNotMapOrList = errors.New("key is not a map or list")
errNoSuchSetting = errors.New("no setting has this key")
errWouldNotLoad = errors.New(
"vaultik would not load the config after this change, " +
"so the file was not changed")
@@ -483,9 +484,9 @@ func writeConfigSet(out *ui.Writer, path, key, value string) error {
keys := strings.Split(key, ".")
_, err = config.SettingType(keys)
if err != nil {
return err
_, ok := config.SettingType(keys)
if !ok {
return fmt.Errorf("%w: %s", errNoSuchSetting, key)
}
err = yamlPathSet(root, keys, value)
@@ -745,9 +746,9 @@ func yamlPathSet(root *yaml.Node, keys []string, value string) error {
// configKeyIsString reports whether the dotted key names a string in
// config.Config, as s3.access_key_id and snapshots.home.exclude.0 do.
func configKeyIsString(keys []string) bool {
typ, err := config.SettingType(keys)
typ, ok := config.SettingType(keys)
return err == nil && typ.Kind() == reflect.String
return ok && typ.Kind() == reflect.String
}
// yamlSetInMapping resolves (creating if needed) the value node for the
+3 -3
View File
@@ -240,11 +240,11 @@ func TestConfigSetKeepsFileThatWouldNotLoad(t *testing.T) {
// Keys that no setting has, on a config that loads and on one
// that does not.
{configWithoutRecipients, "nosuchkey", "1",
`unknown config key "nosuchkey"`},
"no setting has this key: nosuchkey"},
{configWithoutRecipients, "snapshots.home.exlude", "/tmp",
`unknown config key "snapshots.home.exlude"`},
"no setting has this key: snapshots.home.exlude"},
{defaultConfigText(macOS), "compression_lvl", "9",
`unknown config key "compression_lvl"`},
"no setting has this key: compression_lvl"},
// Index 1 of the empty list the missing key gives.
{configWithoutRecipients, "age_recipients.1", testRecipient,
"index out of range"},
+1 -1
View File
@@ -300,7 +300,7 @@ func Parse(data []byte) (*Config, error) {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
err = checkKeys(&root, reflect.TypeFor[Config](), "", map[*yaml.Node]bool{})
err = checkKeys(&root, reflect.TypeFor[Config](), "")
if err != nil {
return nil, err
}
+16 -33
View File
@@ -19,16 +19,13 @@ const yamlMergeTag = "!!merge"
// so its keys are not checked.
const envSection = "env"
var (
errUnknownKey = errors.New("unknown config key")
errAnchorContainsItself = errors.New("anchor contains itself")
)
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 an unknown key error when no setting has that key.
func SettingType(keys []string) (reflect.Type, error) {
// 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 {
@@ -38,16 +35,16 @@ func SettingType(keys []string) (reflect.Type, error) {
case typ.Kind() == reflect.Struct:
field, ok := yamlField(typ, key)
if !ok {
return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, "."))
return nil, false
}
typ = field.Type
default:
return nil, fmt.Errorf("%w %q", errUnknownKey, strings.Join(keys, "."))
return nil, false
}
}
return typ, nil
return typ, true
}
// yamlField returns the field of struct type typ whose yaml tag names key.
@@ -65,43 +62,31 @@ func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
// 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.
func checkKeys(node *yaml.Node, typ reflect.Type, path string,
following map[*yaml.Node]bool,
) error {
// 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, following)
err := checkKeys(child, typ, path)
if err != nil {
return err
}
}
case yaml.AliasNode:
if following[node] {
return fmt.Errorf("%w: %q on line %d",
errAnchorContainsItself, node.Value, node.Line)
}
following[node] = true
defer delete(following, node)
return checkKeys(node.Alias, typ, path, following)
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)), following)
err := checkKeys(child, typ.Elem(), joinKey(path, strconv.Itoa(i)))
if err != nil {
return err
}
}
case yaml.MappingNode:
return checkMappingKeys(node, typ, path, following)
return checkMappingKeys(node, typ, path)
case yaml.ScalarNode:
// A scalar has no keys.
}
@@ -120,13 +105,11 @@ func checkFilledKeys(data map[string]any) error {
return fmt.Errorf("failed to marshal config data: %w", err)
}
return checkKeys(&filled, reflect.TypeFor[Config](), "", map[*yaml.Node]bool{})
return checkKeys(&filled, reflect.TypeFor[Config](), "")
}
// checkMappingKeys is checkKeys for a map.
func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
following map[*yaml.Node]bool,
) error {
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]
@@ -138,7 +121,7 @@ func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
}
for _, m := range merged {
err := checkKeys(m, typ, path, following)
err := checkKeys(m, typ, path)
if err != nil {
return err
}
@@ -174,7 +157,7 @@ func checkMappingKeys(node *yaml.Node, typ reflect.Type, path string,
return nil
}
err := checkKeys(value, valueType, keyPath, following)
err := checkKeys(value, valueType, keyPath)
if err != nil {
return err
}
-19
View File
@@ -65,25 +65,6 @@ func TestParseRefusesUnknownKey(t *testing.T) {
}
}
// TestParseRefusesAnchorThatContainsItself checks that a YAML merge key (<<)
// naming the anchor of the map it sits in fails the parse with an error.
func TestParseRefusesAnchorThatContainsItself(t *testing.T) {
t.Parallel()
data := strings.Replace(keysTestConfig, " test:", " test: &test", 1) +
" <<: *test\n"
_, err := Parse([]byte(data))
if !errors.Is(err, errAnchorContainsItself) {
t.Fatalf("Parse() error = %v, want errAnchorContainsItself", err)
}
want := `anchor contains itself: "test" on line 7`
if err.Error() != want {
t.Errorf("Parse() error = %q, want %q", err.Error(), want)
}
}
// TestParseAcceptsKnownKeys checks that a config with a ${...} reference
// and a YAML merge key (<<) still parses.
func TestParseAcceptsKnownKeys(t *testing.T) {