From 3e70ad12853527bba201b206a20265604d4d6efb Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 6 Sep 2026 19:12:19 -0500 Subject: [PATCH] Reject unsupported inline pointer schemas safely --- docs/config.md | 4 +++- docs/roadmap/remediation.md | 6 ++++++ schema.go | 8 ++++++++ schema_test.go | 38 +++++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 4171d22..32c0a79 100644 --- a/docs/config.md +++ b/docs/config.md @@ -589,7 +589,9 @@ Unexported fields and `yaml:"-"` are ignored. `omitempty` is accepted but does n change binding or projection. An explicit `yaml:",inline"` flattens a struct or pointer-to-struct, or designates at most one string-keyed inline map for otherwise unmatched names. Ambiguous names, named inline fields, multiple inline maps, and -unsupported or malformed tag options are schema errors. Embedded fields are ordinary +unsupported or malformed tag options are schema errors. Inline pointers must +terminate in a struct; pointer-to-map and recursive pointer-only inline declarations +are rejected before the defaults factory runs. Embedded fields are ordinary named fields unless explicitly inlined. `configkit:"secret"` is the only current ConfigKit field tag and marks its bound field or container for secret classification and redacted inspection. diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index cf1ef8a..5dc1ece 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -188,6 +188,12 @@ existing descendants remain explainable after parent replacement. ## 7. P2 — Reject unsupported inline pointer-to-map schemas safely +Status: complete. Schema construction rejects inline map pointers, including +multiple pointer levels, and detects nonterminating inline pointer chains. Public +regressions verify safe errors before defaults run; supported inline coverage stays +green. Full tests pass with the issue-8 TMPDIR workaround; vet, build and whitespace +checks pass. + Evidence: [schema.go](../../schema.go), `describeFields`, dereferences inline field types before choosing the map branch, then stores a map descriptor for a field whose actual reflected value is a pointer. [projection.go](../../projection.go) diff --git a/schema.go b/schema.go index 9eda711..e983b43 100644 --- a/schema.go +++ b/schema.go @@ -212,9 +212,17 @@ func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current refle return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"} } inlineType := field.Type + seenPointers := make(map[reflect.Type]bool) for inlineType.Kind() == reflect.Pointer { + if seenPointers[inlineType] { + return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline pointer chain must terminate in a struct"} + } + seenPointers[inlineType] = true inlineType = inlineType.Elem() } + if inlineType.Kind() == reflect.Map && field.Type.Kind() == reflect.Pointer { + return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline maps cannot be pointers"} + } switch inlineType.Kind() { case reflect.Struct: if b.building[inlineType] { diff --git a/schema_test.go b/schema_test.go index 1072f9b..27418a6 100644 --- a/schema_test.go +++ b/schema_test.go @@ -1,6 +1,7 @@ package configkit import ( + "errors" "reflect" "testing" "time" @@ -302,3 +303,40 @@ func mustPath(t *testing.T, pointer string) Path { } return path } + +func TestInlineMapPointersFailBeforeDefaults(t *testing.T) { + type cfg struct { + Extra *map[string]int `yaml:",inline"` + } + for _, nonNil := range []bool{false, true} { + called := false + _, err := Load(NoSource(), Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { + called = true + if nonNil { + m := map[string]int{"value": 1} + return cfg{Extra: &m} + } + return cfg{} + }}}) + var loadErr *LoadError + if !errors.As(err, &loadErr) || called { + t.Fatalf("invalid inline schema reached defaults: called=%v error=%v", called, err) + } + if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != CodeSchemaInline { + t.Fatal("missing inline schema diagnostic") + } + } + type pointer *pointer + type recursive struct { + Extra pointer `yaml:",inline"` + } + if _, err := Load[recursive](NoSource()); err == nil { + t.Fatal("recursive inline pointer was accepted") + } + type double struct { + Extra **map[string]int `yaml:",inline"` + } + if _, err := Load[double](NoSource()); err == nil { + t.Fatal("double inline map pointer was accepted") + } +}