From d2940f7271b291dd282e14131a0efb4177c974d5 Mon Sep 17 00:00:00 2001 From: Samran Asif Date: Thu, 1 Oct 2026 10:22:14 +0500 Subject: [PATCH 1/4] fix: do not traverse struct fields if type has parser or implements TextUnmarshaler (#440) Signed-off-by: Samran Asif --- env.go | 33 ++++++++++++++++++++++++--------- env_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/env.go b/env.go index c3ac260a..8cbc336c 100644 --- a/env.go +++ b/env.go @@ -379,7 +379,7 @@ func doParseField( if !refField.CanSet() { return nil } - if refField.Kind() == reflect.Pointer && refField.Elem().Kind() == reflect.Struct && !refField.IsNil() { + if refField.Kind() == reflect.Pointer && refField.Elem().Kind() == reflect.Struct && !refField.IsNil() && !hasCustomOrTextParserType(refTypeField.Type, opts.FuncMap) { return parseInternal(refField.Interface(), processField, optionsWithEnvPrefix(refTypeField, opts)) } if refField.Kind() == reflect.Struct && refField.CanAddr() && refField.Type().Name() == "" { @@ -404,31 +404,31 @@ func doParseField( refField = refField.Elem() } - if refField.Kind() == reflect.Struct { + if refField.Kind() == reflect.Struct && !hasCustomOrTextParserType(refTypeField.Type, opts.FuncMap) { return doParse(refField, processField, optionsWithEnvPrefix(refTypeField, opts)) } - if isSliceOfStructs(refTypeField) { + if isSliceOfStructs(refTypeField, opts) { return doParseSlice(refField, processField, optionsWithEnvPrefix(refTypeField, opts)) } return nil } -func isSliceOfStructs(refTypeField reflect.StructField) bool { +func isSliceOfStructs(refTypeField reflect.StructField, opts Options) bool { field := refTypeField.Type // *[]struct if field.Kind() == reflect.Pointer { field = field.Elem() - if field.Kind() == reflect.Slice && field.Elem().Kind() == reflect.Struct { - return true - } } - // []struct{} if field.Kind() == reflect.Slice && field.Elem().Kind() == reflect.Struct { - return true + elemType := field.Elem() + if elemType.Kind() == reflect.Pointer { + elemType = elemType.Elem() + } + return !hasCustomOrTextParserType(elemType, opts.FuncMap) } return false @@ -851,3 +851,18 @@ func ToMap(env []string) map[string]string { func isInvalidPtr(v reflect.Value) bool { return reflect.Pointer == v.Kind() && v.Elem().Kind() == reflect.Invalid } + +var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() //nolint:gochecknoglobals + +func hasCustomOrTextParserType(t reflect.Type, funcMap map[reflect.Type]ParserFunc) bool { + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if _, ok := funcMap[t]; ok { + return true + } + if t.Implements(textUnmarshalerType) || reflect.PointerTo(t).Implements(textUnmarshalerType) { + return true + } + return false +} diff --git a/env_test.go b/env_test.go index 2fe79c31..2ada8199 100644 --- a/env_test.go +++ b/env_test.go @@ -2417,3 +2417,41 @@ func TestEnvBleed(t *testing.T) { isEqual(t, "", cfg.Foo) }) } + +func TestUseFieldNameByDefault_URLAndTextUnmarshaler(t *testing.T) { + type config struct { + URL url.URL `env:"URL"` + } + + environ := map[string]string{ + "URL": "http://example.com/a/b", + "PATH": "/usr/bin", + "HOST": "evil", + } + + t.Run("by tag", func(t *testing.T) { + var cfg config + err := ParseWithOptions(&cfg, Options{Environment: environ}) + isNoErr(t, err) + isEqual(t, "http://example.com/a/b", cfg.URL.String()) + }) + + t.Run("with UseFieldNameByDefault", func(t *testing.T) { + var cfg config + err := ParseWithOptions(&cfg, Options{Environment: environ, UseFieldNameByDefault: true}) + isNoErr(t, err) + isEqual(t, "http://example.com/a/b", cfg.URL.String()) + }) + + t.Run("with UseFieldNameByDefault and RequiredIfNoDef", func(t *testing.T) { + var cfg config + err := ParseWithOptions(&cfg, Options{ + Environment: map[string]string{"URL": "http://example.com/a/b"}, + UseFieldNameByDefault: true, + RequiredIfNoDef: true, + }) + isNoErr(t, err) + isEqual(t, "http://example.com/a/b", cfg.URL.String()) + }) +} + From 09d1631c4569e1c61020961214a048dbdcff5cf6 Mon Sep 17 00:00:00 2001 From: Samran Asif Date: Fri, 2 Oct 2026 07:54:08 +0500 Subject: [PATCH 2/4] test: add custom TextUnmarshaler cases to TestUseFieldNameByDefault_URLAndTextUnmarshaler Signed-off-by: Samran Asif --- env_test.go | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/env_test.go b/env_test.go index 2ada8199..f3d8f7e1 100644 --- a/env_test.go +++ b/env_test.go @@ -2418,15 +2418,29 @@ func TestEnvBleed(t *testing.T) { }) } +type customTextUnmarshaler struct { + Value string +} + +func (c *customTextUnmarshaler) UnmarshalText(text []byte) error { + c.Value = string(text) + return nil +} + func TestUseFieldNameByDefault_URLAndTextUnmarshaler(t *testing.T) { type config struct { - URL url.URL `env:"URL"` + URL url.URL `env:"URL"` + Custom customTextUnmarshaler `env:"CUSTOM"` + CustomPtr *customTextUnmarshaler `env:"CUSTOM_PTR"` } environ := map[string]string{ - "URL": "http://example.com/a/b", - "PATH": "/usr/bin", - "HOST": "evil", + "URL": "http://example.com/a/b", + "PATH": "/usr/bin", + "HOST": "evil", + "CUSTOM": "custom-value", + "CUSTOM_PTR": "custom-ptr-value", + "VALUE": "evil-nested", } t.Run("by tag", func(t *testing.T) { @@ -2434,6 +2448,8 @@ func TestUseFieldNameByDefault_URLAndTextUnmarshaler(t *testing.T) { err := ParseWithOptions(&cfg, Options{Environment: environ}) isNoErr(t, err) isEqual(t, "http://example.com/a/b", cfg.URL.String()) + isEqual(t, "custom-value", cfg.Custom.Value) + isEqual(t, "custom-ptr-value", cfg.CustomPtr.Value) }) t.Run("with UseFieldNameByDefault", func(t *testing.T) { @@ -2441,17 +2457,26 @@ func TestUseFieldNameByDefault_URLAndTextUnmarshaler(t *testing.T) { err := ParseWithOptions(&cfg, Options{Environment: environ, UseFieldNameByDefault: true}) isNoErr(t, err) isEqual(t, "http://example.com/a/b", cfg.URL.String()) + isEqual(t, "custom-value", cfg.Custom.Value) + isEqual(t, "custom-ptr-value", cfg.CustomPtr.Value) }) t.Run("with UseFieldNameByDefault and RequiredIfNoDef", func(t *testing.T) { var cfg config err := ParseWithOptions(&cfg, Options{ - Environment: map[string]string{"URL": "http://example.com/a/b"}, + Environment: map[string]string{ + "URL": "http://example.com/a/b", + "CUSTOM": "custom-value", + "CUSTOM_PTR": "custom-ptr-value", + }, UseFieldNameByDefault: true, RequiredIfNoDef: true, }) isNoErr(t, err) isEqual(t, "http://example.com/a/b", cfg.URL.String()) + isEqual(t, "custom-value", cfg.Custom.Value) + isEqual(t, "custom-ptr-value", cfg.CustomPtr.Value) }) } + From c54897ce30b37857318150aa4a60329b389ece32 Mon Sep 17 00:00:00 2001 From: Samran Asif Date: Fri, 16 Oct 2026 20:05:14 -0700 Subject: [PATCH 3/4] style(test): remove trailing blank lines from env_test.go Signed-off-by: Samran Asif --- env_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/env_test.go b/env_test.go index f3d8f7e1..b66b74fd 100644 --- a/env_test.go +++ b/env_test.go @@ -2478,5 +2478,3 @@ func TestUseFieldNameByDefault_URLAndTextUnmarshaler(t *testing.T) { isEqual(t, "custom-ptr-value", cfg.CustomPtr.Value) }) } - - From 594c55338f904dd0fc197317c223b307b26253e5 Mon Sep 17 00:00:00 2001 From: Samran Asif Date: Fri, 30 Oct 2026 05:28:43 -0700 Subject: [PATCH 4/4] fix: preserve prefix-only containers with registered scalar parser (#440) Signed-off-by: Samran Asif --- env.go | 40 ++++++++++++++++++----------- env_test.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/env.go b/env.go index 8cbc336c..c4e0bfda 100644 --- a/env.go +++ b/env.go @@ -379,18 +379,24 @@ func doParseField( if !refField.CanSet() { return nil } - if refField.Kind() == reflect.Pointer && refField.Elem().Kind() == reflect.Struct && !refField.IsNil() && !hasCustomOrTextParserType(refTypeField.Type, opts.FuncMap) { - return parseInternal(refField.Interface(), processField, optionsWithEnvPrefix(refTypeField, opts)) - } - if refField.Kind() == reflect.Struct && refField.CanAddr() && refField.Type().Name() == "" { - return parseInternal(refField.Addr().Interface(), processField, optionsWithEnvPrefix(refTypeField, opts)) - } params, err := parseFieldParams(refTypeField, opts) if err != nil { return err } + explicitKey, _ := parseKeyForOption(refTypeField.Tag.Get(opts.TagName)) + _, hasPrefix := refTypeField.Tag.Lookup(opts.PrefixTagName) + _, hasValue := opts.Environment[params.Key] + scalar := isScalarStruct(refField.Type(), opts.FuncMap) && + (explicitKey != "" || params.HasDefaultValue || params.OwnKey != "" && !hasPrefix && hasValue) + if !scalar && refField.Kind() == reflect.Pointer && refField.Elem().Kind() == reflect.Struct && !refField.IsNil() { + return parseInternal(refField.Interface(), processField, optionsWithEnvPrefix(refTypeField, opts)) + } + if !scalar && refField.Kind() == reflect.Struct && refField.CanAddr() && refField.Type().Name() == "" { + return parseInternal(refField.Addr().Interface(), processField, optionsWithEnvPrefix(refTypeField, opts)) + } + if params.Ignored { return nil } @@ -404,7 +410,11 @@ func doParseField( refField = refField.Elem() } - if refField.Kind() == reflect.Struct && !hasCustomOrTextParserType(refTypeField.Type, opts.FuncMap) { + if scalar { + return nil + } + + if refField.Kind() == reflect.Struct { return doParse(refField, processField, optionsWithEnvPrefix(refTypeField, opts)) } @@ -428,7 +438,7 @@ func isSliceOfStructs(refTypeField reflect.StructField, opts Options) bool { if elemType.Kind() == reflect.Pointer { elemType = elemType.Elem() } - return !hasCustomOrTextParserType(elemType, opts.FuncMap) + return !isScalarStruct(elemType, opts.FuncMap) } return false @@ -854,15 +864,15 @@ func isInvalidPtr(v reflect.Value) bool { var textUnmarshalerType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() //nolint:gochecknoglobals -func hasCustomOrTextParserType(t reflect.Type, funcMap map[reflect.Type]ParserFunc) bool { - if t.Kind() == reflect.Pointer { - t = t.Elem() +func isScalarStruct(fieldType reflect.Type, funcMap map[reflect.Type]ParserFunc) bool { + if fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() } - if _, ok := funcMap[t]; ok { - return true + if fieldType.Kind() != reflect.Struct { + return false } - if t.Implements(textUnmarshalerType) || reflect.PointerTo(t).Implements(textUnmarshalerType) { + if _, ok := funcMap[fieldType]; ok { return true } - return false + return fieldType.Implements(textUnmarshalerType) || reflect.PointerTo(fieldType).Implements(textUnmarshalerType) } diff --git a/env_test.go b/env_test.go index b66b74fd..f56116a9 100644 --- a/env_test.go +++ b/env_test.go @@ -2478,3 +2478,76 @@ func TestUseFieldNameByDefault_URLAndTextUnmarshaler(t *testing.T) { isEqual(t, "custom-ptr-value", cfg.CustomPtr.Value) }) } + +func TestParseCustomScalarStructAsNestedContainer(t *testing.T) { + type scalar struct { + Value string `env:"VALUE"` + } + for _, required := range []bool{false, true} { + t.Run(fmt.Sprintf("required=%t", required), func(t *testing.T) { + var cfg struct { + Scalar *scalar `env:"SCALAR"` + Nested *scalar `envPrefix:"NESTED_"` + } + cfg.Scalar = &scalar{Value: "old"} + cfg.Nested = &scalar{Value: "old"} + + err := ParseWithOptions(&cfg, Options{ + UseFieldNameByDefault: true, + RequiredIfNoDef: required, + Environment: map[string]string{ + "SCALAR": "scalar", + "NESTED_VALUE": "nested", + }, + FuncMap: map[reflect.Type]ParserFunc{ + reflect.TypeOf(scalar{}): func(value string) (interface{}, error) { + return scalar{Value: value}, nil + }, + }, + }) + + isNoErr(t, err) + isEqual(t, "scalar", cfg.Scalar.Value) + isEqual(t, "nested", cfg.Nested.Value) + }) + for _, test := range []struct { + name string + environment map[string]string + want string + }{ + { + name: "absent inferred input", + environment: map[string]string{"SCALAR": "scalar", "VALUE": "nested"}, + want: "nested", + }, + { + name: "present inferred input", + environment: map[string]string{"SCALAR": "scalar", "NESTED": "parsed", "VALUE": "nested"}, + want: "parsed", + }, + } { + t.Run(fmt.Sprintf("%s/required=%t", test.name, required), func(t *testing.T) { + var cfg struct { + Scalar scalar `env:"SCALAR"` + Nested *scalar + } + cfg.Nested = &scalar{Value: "old"} + + err := ParseWithOptions(&cfg, Options{ + UseFieldNameByDefault: true, + RequiredIfNoDef: required, + Environment: test.environment, + FuncMap: map[reflect.Type]ParserFunc{ + reflect.TypeOf(scalar{}): func(value string) (interface{}, error) { + return scalar{Value: value}, nil + }, + }, + }) + + isNoErr(t, err) + isEqual(t, "scalar", cfg.Scalar.Value) + isEqual(t, test.want, cfg.Nested.Value) + }) + } + } +}