From fe11d30949d9495c9d0992c6b438436b05e5edb1 Mon Sep 17 00:00:00 2001 From: Stefan Amberger <1277330+snamber@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:12:23 +0100 Subject: [PATCH 1/2] Fix: use correct bitSize in ParseInt/ParseUint to prevent integer overflow Use the appropriate bitSize parameter (8, 16, 32, or strconv.IntSize) when parsing integers to ensure overflow is caught at parse time rather than silently truncating during type conversion. Fixes 8 CodeQL 'Incorrect conversion between integer types' alerts. --- config.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/config.go b/config.go index 340aa90..f8fe6e7 100644 --- a/config.go +++ b/config.go @@ -91,7 +91,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref var value int64 var err error if tags.defaultValue != "" { - value, err = strconv.ParseInt(tags.defaultValue, 10, 64) + value, err = strconv.ParseInt(tags.defaultValue, 10, strconv.IntSize) if err != nil { return fmt.Errorf("failed to parse int value %s for field %s: %w", tags.defaultValue, field.Name, err) } @@ -111,7 +111,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref var value int64 var err error if tags.defaultValue != "" { - value, err = strconv.ParseInt(tags.defaultValue, 10, 64) + value, err = strconv.ParseInt(tags.defaultValue, 10, 8) if err != nil { return fmt.Errorf("failed to parse int value %s for field %s: %w", tags.defaultValue, field.Name, err) } @@ -121,7 +121,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: int8(value), //nolint: gosec + Value: int8(value), //#nosec G115 -- ParseInt with bitSize 8 guarantees value fits Sources: sources, } apply = func(cmd *cli.Command) { @@ -131,7 +131,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref var value int64 var err error if tags.defaultValue != "" { - value, err = strconv.ParseInt(tags.defaultValue, 10, 64) + value, err = strconv.ParseInt(tags.defaultValue, 10, 16) if err != nil { return fmt.Errorf("failed to parse int value %s for field %s: %w", tags.defaultValue, field.Name, err) } @@ -141,7 +141,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: int16(value), //nolint: gosec + Value: int16(value), //#nosec G115 -- ParseInt with bitSize 16 guarantees value fits Sources: sources, } apply = func(cmd *cli.Command) { @@ -151,7 +151,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref var value int64 var err error if tags.defaultValue != "" { - value, err = strconv.ParseInt(tags.defaultValue, 10, 64) + value, err = strconv.ParseInt(tags.defaultValue, 10, 32) if err != nil { return fmt.Errorf("failed to parse int value %s for field %s: %w", tags.defaultValue, field.Name, err) } @@ -161,7 +161,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: int32(value), //nolint: gosec + Value: int32(value), //#nosec G115 -- ParseInt with bitSize 32 guarantees value fits Sources: sources, } apply = func(cmd *cli.Command) { @@ -215,7 +215,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref var value uint64 var err error if tags.defaultValue != "" { - value, err = strconv.ParseUint(tags.defaultValue, 10, 64) + value, err = strconv.ParseUint(tags.defaultValue, 10, strconv.IntSize) if err != nil { return fmt.Errorf("failed to parse uint value %s for field %s: %w", tags.defaultValue, field.Name, err) } @@ -236,7 +236,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref var value uint64 var err error if tags.defaultValue != "" { - value, err = strconv.ParseUint(tags.defaultValue, 10, 64) + value, err = strconv.ParseUint(tags.defaultValue, 10, 8) if err != nil { return fmt.Errorf("failed to parse uint value %s for field %s: %w", tags.defaultValue, field.Name, err) } @@ -247,7 +247,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: uint8(value), //nolint: gosec + Value: uint8(value), //#nosec G115 -- ParseUint with bitSize 8 guarantees value fits Sources: sources, } apply = func(cmd *cli.Command) { @@ -257,7 +257,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref var value uint64 var err error if tags.defaultValue != "" { - value, err = strconv.ParseUint(tags.defaultValue, 10, 64) + value, err = strconv.ParseUint(tags.defaultValue, 10, 16) if err != nil { return fmt.Errorf("failed to parse uint value %s for field %s: %w", tags.defaultValue, field.Name, err) } @@ -268,7 +268,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: uint16(value), //nolint: gosec + Value: uint16(value), //#nosec G115 -- ParseUint with bitSize 16 guarantees value fits Sources: sources, } apply = func(cmd *cli.Command) { @@ -278,7 +278,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref var value uint64 var err error if tags.defaultValue != "" { - value, err = strconv.ParseUint(tags.defaultValue, 10, 64) + value, err = strconv.ParseUint(tags.defaultValue, 10, 32) if err != nil { return fmt.Errorf("failed to parse uint value %s for field %s: %w", tags.defaultValue, field.Name, err) } @@ -289,7 +289,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: uint32(value), //nolint: gosec + Value: uint32(value), //#nosec G115 -- ParseUint with bitSize 32 guarantees value fits Sources: sources, } apply = func(cmd *cli.Command) { From 8cffa3cc1cefc1eb1ef1060fce1164a8255a8da7 Mon Sep 17 00:00:00 2001 From: Lukas Bindreiter Date: Fri, 2 Jan 2026 11:25:25 +0100 Subject: [PATCH 2/2] Update go and golangci-lint --- .github/workflows/main.yml | 10 +-- .golangci.yaml | 21 ++++-- config.go | 138 ++++++++++++++++++++++++------------ examples/07_marshal/main.go | 2 + go.mod | 21 +++--- go.sum | 45 +++++------- marshal.go | 3 +- structconf.go | 10 +++ structconf_test.go | 13 ++++ tags.go | 14 ++-- toml.go | 3 +- validate.go | 4 ++ 12 files changed, 182 insertions(+), 102 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cee89c2..321b43e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,16 +13,16 @@ jobs: strategy: matrix: os: [Ubuntu] - go-version: ["1.24.x"] + go-version: ["1.25.x"] runs-on: ${{ matrix.os }}-latest permissions: contents: read # for golangci-lint-action steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: lfs: true - name: Setup Go ${{ matrix.go-version }} - uses: actions/setup-go@v5 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: Install dependencies @@ -39,7 +39,7 @@ jobs: paths: "test-report.xml" if: always() - name: Lint - uses: golangci/golangci-lint-action@v8 + uses: golangci/golangci-lint-action@v9 with: - version: v2.1.6 + version: v2.7.2 args: --timeout=5m --verbose diff --git a/.golangci.yaml b/.golangci.yaml index 9fdf301..11e3545 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -2,7 +2,7 @@ --- version: "2" linters: - enable: # list taken from https://golangci-lint.run/usage/linters/ - last updated 2025-02-14 for v1.64.5 + enable: # list taken from https://golangci-lint.run/usage/linters/ - last updated 2026-01-02 for v2.7.2 # enabled by default, but list them here to be explicit - errcheck - govet @@ -10,6 +10,7 @@ linters: - staticcheck - unused # other linters (that would be disabled by default) + - arangolint # linter for arangoDB - asasalint # checks for pass []any as any in variadic func(...any) - asciicheck # checks that your code does not contain non-ASCII identifiers - bidichk # checks for dangerous unicode character sequences @@ -25,6 +26,7 @@ linters: #- dupl # tool for code clone detection - dupword # checks for duplicate words in the source code - durationcheck # checks for two durations multiplied together + - embeddedstructfieldcheck # embedded types should be at the top of a struct #- err113 # checks the errors handling expressions - errchkjson # checks types passed to the json encoding functions. - errname # checks that sentinel errors are prefixed with the Err and error types are suffixed with the Error @@ -35,6 +37,7 @@ linters: - fatcontext # finds nested context.WithValue calls in loops #- forbidigo # forbids identifiers #- forcetypeassert # finds forced type assertions + - funcorder # checks that functions are in the right order #- funlen # Tool for detection of long functions #- ginkgolinter # Enforces the Ginkgo testing package guidelines. - gocheckcompilerdirectives # validates go compiler directive comments (//go:) @@ -45,6 +48,7 @@ linters: - goconst # finds repeated strings that could be replaced by a constant - gocritic # provides diagnostics that check for bugs, performance and style issues #- gocyclo # Computes and checks the cyclomatic complexity of functions + - godoclint # Checks golang docs best practices (godoc) #- godot # Check if comments end in a period #- godox # Tool for detection of FIXME, TODO and other comment keywords #- goheader # Checks is file header matches to pattern @@ -59,6 +63,7 @@ linters: #- inamedparam # Reports interfaces with unnamed method parameters. - interfacebloat # Checks the number of methods in an interface - intrange # finds places where for loops could make use of an integer range + - iotamixing # checks that iota is not mixed with other constants #- ireturn # Accept Interfaces, Return Concrete Types #- lll # Reports long lines - loggercheck # checks key value pairs for common logger libraries (kitlog,klog,logr,zap) @@ -67,6 +72,7 @@ linters: - mirror # reports wrong mirror patterns of bytes/strings usage - misspell # finds commonly misspelled English words in comments #- mnd # Detects magic numbers + - modernize # reports wrong mirror patterns of bytes/strings usage - musttag # enforces field tags in (un)marshaled structs - nakedret # finds naked returns in functions greater than a specified function length #- nestif # Reports deeply nested if statements @@ -74,13 +80,13 @@ linters: - nilnesserr # Reports constructs that checks for err != nil, but returns a different nil value error. - nilnil # checks that there is no simultaneous return of nil error and an invalid value #- nlreturn # Checks for a new line before return and branch statements to increase code clarity + #- noinlineerr # Disallows inline error handling - noctx # finds sending http request without context.Context - nolintlint # reports ill-formed or insufficient nolint directives - nonamedreturns # reports all named returns - nosprintfhostport # checks for misuse of Sprintf to construct a host with port in a URL - - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative #- paralleltest # detects missing usage of t.Parallel() method in your Go test - - perfsprint # Checks that fmt.Sprintf can be replaced with a faster alternative. + - perfsprint # checks that fmt.Sprintf can be replaced with a faster alternative - prealloc # finds slice declarations that could potentially be preallocated - predeclared # finds code that shadows one of Go's predeclared identifiers - promlinter # checks Prometheus metrics naming via promlint @@ -101,15 +107,16 @@ linters: - tparallel # detects inappropriate usage of t.Parallel() method in your Go test codes - unconvert # removes unnecessary type conversions - unparam # reports unused function parameters + - unqueryvet # sql linter disallowing select * - usestdlibvars # detects the possibility to use variables/constants from the Go standard library - usetesting # reports uses of functions with replacement inside the testing package #- varnamelen # checks that the length of a variable's name matches its scope - wastedassign # finds wasted assignment statements - whitespace # detects leading and trailing whitespace #- wrapcheck # Checks that errors returned from external packages are wrapped - #- wsl # whitespace linter - add or remove empty lines + #- wsl # (deprecated) + # - wsl_v5 # whitespace linter - add or remove empty lines #- zerologlint # checks wrong usage of zerolog - #- tenv # deprecated in favor of usetesting settings: gosec: excludes: @@ -147,7 +154,7 @@ linters: strict: false exclusions: generated: strict - warn-unused: true + warn-unused: false presets: - comments - common-false-positives @@ -164,4 +171,4 @@ formatters: - gci - gofmt - gofumpt - - goimports + - goimports \ No newline at end of file diff --git a/config.go b/config.go index f8fe6e7..73b0afb 100644 --- a/config.go +++ b/config.go @@ -48,6 +48,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref tomlKeys = append(tomlKeys, tags.toml) tomlKey = strings.Join(tomlKeys, ".") } + valueSources = append(valueSources, NewValueSourceFromMaps(tomlKey, r.tomlSources...)) } @@ -58,6 +59,7 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref emvKeys = append(emvKeys, tags.env) envKey = strings.Join(emvKeys, "_") } + valueSources = append(valueSources, cli.EnvVar(envKey)) } @@ -70,10 +72,12 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref sources := cli.NewValueSourceChain(valueSources...) - var flag cli.Flag - var apply func(*cli.Command) + var ( + flag cli.Flag + apply func(*cli.Command) + ) - switch field.Type.Kind() { //nolint:exhaustive + switch field.Type.Kind() { //nolint:exhaustive // we have a default: clause that results in an error case reflect.String: flag = &cli.StringFlag{ Name: flagName, @@ -88,80 +92,90 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref fieldValue.SetString(cmd.String(flagName)) } case reflect.Int: - var value int64 - var err error + var value int if tags.defaultValue != "" { - value, err = strconv.ParseInt(tags.defaultValue, 10, strconv.IntSize) + valueParsed, err := strconv.ParseInt(tags.defaultValue, 10, strconv.IntSize) if err != nil { return fmt.Errorf("failed to parse int value %s for field %s: %w", tags.defaultValue, field.Name, err) } + value = int(valueParsed) } + flag = &cli.IntFlag{ Name: flagName, Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: int(value), + Value: value, Sources: sources, } apply = func(cmd *cli.Command) { fieldValue.SetInt(int64(cmd.Int(flagName))) } case reflect.Int8: - var value int64 - var err error + var value int8 + if tags.defaultValue != "" { - value, err = strconv.ParseInt(tags.defaultValue, 10, 8) + valueParsed, err := strconv.ParseInt(tags.defaultValue, 10, 8) if err != nil { return fmt.Errorf("failed to parse int value %s for field %s: %w", tags.defaultValue, field.Name, err) } + + value = int8(valueParsed) } + flag = &cli.Int8Flag{ Name: flagName, Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: int8(value), //#nosec G115 -- ParseInt with bitSize 8 guarantees value fits + Value: value, Sources: sources, } apply = func(cmd *cli.Command) { fieldValue.SetInt(int64(cmd.Int8(flagName))) } case reflect.Int16: - var value int64 - var err error + var value int16 + if tags.defaultValue != "" { - value, err = strconv.ParseInt(tags.defaultValue, 10, 16) + valueParsed, err := strconv.ParseInt(tags.defaultValue, 10, 16) if err != nil { return fmt.Errorf("failed to parse int value %s for field %s: %w", tags.defaultValue, field.Name, err) } + + value = int16(valueParsed) } + flag = &cli.Int16Flag{ Name: flagName, Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: int16(value), //#nosec G115 -- ParseInt with bitSize 16 guarantees value fits + Value: value, Sources: sources, } apply = func(cmd *cli.Command) { fieldValue.SetInt(int64(cmd.Int16(flagName))) } case reflect.Int32: - var value int64 - var err error + var value int32 + if tags.defaultValue != "" { - value, err = strconv.ParseInt(tags.defaultValue, 10, 32) + valueParsed, err := strconv.ParseInt(tags.defaultValue, 10, 32) if err != nil { return fmt.Errorf("failed to parse int value %s for field %s: %w", tags.defaultValue, field.Name, err) } + + value = int32(valueParsed) } + flag = &cli.Int32Flag{ Name: flagName, Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: int32(value), //#nosec G115 -- ParseInt with bitSize 32 guarantees value fits + Value: value, Sources: sources, } apply = func(cmd *cli.Command) { @@ -169,8 +183,11 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref } case reflect.Int64: if _, ok := fieldValue.Interface().(time.Duration); ok { // special handling for time.Duration, which is a int64 - var value time.Duration - var err error + var ( + value time.Duration + err error + ) + if tags.defaultValue != "" { value, err = time.ParseDuration(tags.defaultValue) if err != nil { @@ -190,8 +207,11 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref fieldValue.SetInt(int64(cmd.Duration(flagName))) } } else { - var value int64 - var err error + var ( + value int64 + err error + ) + if tags.defaultValue != "" { value, err = strconv.ParseInt(tags.defaultValue, 10, 64) if err != nil { @@ -212,8 +232,11 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref } } case reflect.Uint: - var value uint64 - var err error + var ( + value uint64 + err error + ) + if tags.defaultValue != "" { value, err = strconv.ParseUint(tags.defaultValue, 10, strconv.IntSize) if err != nil { @@ -233,13 +256,15 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref fieldValue.SetUint(uint64(cmd.Uint(flagName))) } case reflect.Uint8: - var value uint64 - var err error + var value uint8 + if tags.defaultValue != "" { - value, err = strconv.ParseUint(tags.defaultValue, 10, 8) + valueParsed, err := strconv.ParseUint(tags.defaultValue, 10, 8) if err != nil { return fmt.Errorf("failed to parse uint value %s for field %s: %w", tags.defaultValue, field.Name, err) } + + value = uint8(valueParsed) } flag = &cli.Uint8Flag{ @@ -247,20 +272,22 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: uint8(value), //#nosec G115 -- ParseUint with bitSize 8 guarantees value fits + Value: value, Sources: sources, } apply = func(cmd *cli.Command) { fieldValue.SetUint(uint64(cmd.Uint8(flagName))) } case reflect.Uint16: - var value uint64 - var err error + var value uint16 + if tags.defaultValue != "" { - value, err = strconv.ParseUint(tags.defaultValue, 10, 16) + valueParsed, err := strconv.ParseUint(tags.defaultValue, 10, 16) if err != nil { return fmt.Errorf("failed to parse uint value %s for field %s: %w", tags.defaultValue, field.Name, err) } + + value = uint16(valueParsed) } flag = &cli.Uint16Flag{ @@ -268,20 +295,22 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: uint16(value), //#nosec G115 -- ParseUint with bitSize 16 guarantees value fits + Value: value, Sources: sources, } apply = func(cmd *cli.Command) { fieldValue.SetUint(uint64(cmd.Uint16(flagName))) } case reflect.Uint32: - var value uint64 - var err error + var value uint32 + if tags.defaultValue != "" { - value, err = strconv.ParseUint(tags.defaultValue, 10, 32) + valueParsed, err := strconv.ParseUint(tags.defaultValue, 10, 32) if err != nil { return fmt.Errorf("failed to parse uint value %s for field %s: %w", tags.defaultValue, field.Name, err) } + + value = uint32(valueParsed) } flag = &cli.Uint32Flag{ @@ -289,15 +318,18 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: uint32(value), //#nosec G115 -- ParseUint with bitSize 32 guarantees value fits + Value: value, Sources: sources, } apply = func(cmd *cli.Command) { fieldValue.SetUint(uint64(cmd.Uint32(flagName))) } case reflect.Uint64: - var value uint64 - var err error + var ( + value uint64 + err error + ) + if tags.defaultValue != "" { value, err = strconv.ParseUint(tags.defaultValue, 10, 64) if err != nil { @@ -317,13 +349,15 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref fieldValue.SetUint(cmd.Uint64(flagName)) } case reflect.Float32: - var value float64 - var err error + var value float32 + if tags.defaultValue != "" { - value, err = strconv.ParseFloat(tags.defaultValue, 64) + valueParsed, err := strconv.ParseFloat(tags.defaultValue, 32) if err != nil { return fmt.Errorf("failed to parse float value %s for field %s: %w", tags.defaultValue, field.Name, err) } + + value = float32(valueParsed) } flag = &cli.Float32Flag{ @@ -331,15 +365,18 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref Aliases: tags.aliases, Usage: tags.help, DefaultText: tags.defaultValue, - Value: float32(value), + Value: value, Sources: sources, } apply = func(cmd *cli.Command) { fieldValue.SetFloat(float64(cmd.Float32(flagName))) } case reflect.Float64: - var value float64 - var err error + var ( + value float64 + err error + ) + if tags.defaultValue != "" { value, err = strconv.ParseFloat(tags.defaultValue, 64) if err != nil { @@ -359,8 +396,11 @@ func (r *structReflector) processField(field reflect.StructField, fieldValue ref fieldValue.SetFloat(cmd.Float64(flagName)) } case reflect.Bool: - var value bool - var err error + var ( + value bool + err error + ) + if tags.defaultValue != "" { value, err = strconv.ParseBool(tags.defaultValue) if err != nil { @@ -412,6 +452,7 @@ func (r *structReflector) recurseStruct(anyStruct any, parents []*configFieldTag if err != nil { return err } + continue } @@ -419,10 +460,12 @@ func (r *structReflector) recurseStruct(anyStruct any, parents []*configFieldTag if fieldValue.IsNil() { fieldValue.Set(reflect.New(fieldType.Type.Elem())) } + err := r.recurseStruct(fieldValue.Interface(), nested) if err != nil { return err } + continue } @@ -446,5 +489,6 @@ func NewStructConfigurator(anyStruct any, tomlSources []cli.MapSource) (StructRe if err != nil { return nil, err } + return reflector, nil } diff --git a/examples/07_marshal/main.go b/examples/07_marshal/main.go index 5848fb8..741d5f4 100644 --- a/examples/07_marshal/main.go +++ b/examples/07_marshal/main.go @@ -29,6 +29,7 @@ func main() { if err != nil { panic(err) } + fmt.Println(asMap) // includes an integration with log/slog to convert the config struct to a recursive slog.Group structure @@ -36,5 +37,6 @@ func main() { if err != nil { panic(err) } + slog.Info("Program config loaded successfully", config) } diff --git a/go.mod b/go.mod index 079eead..9a8b631 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,25 @@ module github.com/tilebox/structconf -go 1.23.4 +go 1.24.0 require ( - github.com/BurntSushi/toml v1.5.0 - github.com/go-playground/validator/v10 v10.26.0 + github.com/BurntSushi/toml v1.6.0 + github.com/go-playground/validator/v10 v10.30.1 github.com/iancoleman/strcase v0.3.0 - github.com/samber/lo v1.50.0 - github.com/stretchr/testify v1.10.0 - github.com/urfave/cli/v3 v3.3.3 + github.com/samber/lo v1.52.0 + github.com/stretchr/testify v1.11.1 + github.com/urfave/cli/v3 v3.6.1 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/crypto v0.38.0 // indirect - golang.org/x/net v0.40.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.25.0 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3edbf61..a34baba 100644 --- a/go.sum +++ b/go.sum @@ -1,43 +1,36 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= -github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/samber/lo v1.50.0 h1:XrG0xOeHs+4FQ8gJR97zDz5uOFMW7OwFWiFVzqopKgY= -github.com/samber/lo v1.50.0/go.mod h1:RjZyNk6WSnUFRKK6EyOhsRJMqft3G+pg7dCWHQCWvsc= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= -github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= -golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo= +github.com/urfave/cli/v3 v3.6.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/marshal.go b/marshal.go index ae65528..95e9e0b 100644 --- a/marshal.go +++ b/marshal.go @@ -92,7 +92,7 @@ func marshalStruct(anyStruct any, into map[string]any, nameFromTags func(t *conf } func getFieldValue(field reflect.StructField, fieldValue reflect.Value, tags *configFieldTags) (any, error) { - switch field.Type.Kind() { //nolint:exhaustive + switch field.Type.Kind() { //nolint:exhaustive // we have a default: clause that results in an error case reflect.String: if tags.isSecret { return redactSecret(fieldValue.String()), nil @@ -126,6 +126,7 @@ func mapToSlogAttrs(m map[string]any) []any { attrs = append(attrs, slog.String(key, fmt.Sprintf("%v", value))) } } + return attrs } diff --git a/structconf.go b/structconf.go index 2525afb..bd0fc58 100644 --- a/structconf.go +++ b/structconf.go @@ -108,6 +108,7 @@ func loadConfig(configPointer any, programName string, opts ...Option) error { } tomlSources := make([]cli.MapSource, 0) + var loadConfigFlag cli.Flag if cfg.loadConfigFlagName != "" { loadConfigFlag = &cli.StringSliceFlag{ @@ -119,6 +120,7 @@ func loadConfig(configPointer any, programName string, opts ...Option) error { if err != nil { return err } + flags := config.Flags() flags = append(flags, loadConfigFlag) if duplicate := firstDuplicateFlagName(flags); duplicate != "" { @@ -149,6 +151,7 @@ func loadConfig(configPointer any, programName string, opts ...Option) error { return nil }, } + err = cmd.Run(context.Background(), os.Args) if err != nil { if stdout.Len() > 0 { @@ -156,6 +159,7 @@ func loadConfig(configPointer any, programName string, opts ...Option) error { } return err } + if stdout.Len() > 0 { // help was requested -> return an error so that we can exit return &helpRequestedError{ helpText: stdout.String(), @@ -167,10 +171,12 @@ func loadConfig(configPointer any, programName string, opts ...Option) error { if err != nil { return err } + flags := config.Flags() if loadConfigFlag != nil { flags = append(flags, loadConfigFlag) } + if duplicate := firstDuplicateFlagName(flags); duplicate != "" { return fmt.Errorf("duplicate flag: --%s", duplicate) } @@ -201,16 +207,19 @@ func loadConfig(configPointer any, programName string, opts ...Option) error { } return err } + if stdout.Len() > 0 { // help was requested -> return an error so that we can exit return &helpRequestedError{ helpText: strings.TrimSpace(stdout.String()), } } + return nil } func firstDuplicateFlagName(flags []cli.Flag) string { seen := make(map[string]bool) + for _, flag := range flags { for _, name := range flag.Names() { isDuplicate, ok := seen[name] @@ -220,5 +229,6 @@ func firstDuplicateFlagName(flags []cli.Flag) string { seen[name] = true } } + return "" } diff --git a/structconf_test.go b/structconf_test.go index 9f49ce6..100d157 100644 --- a/structconf_test.go +++ b/structconf_test.go @@ -49,11 +49,14 @@ func Test_loadConfigFullyTagged(t *testing.T) { wantDuration: 2 * time.Second, }, } + _ = tests for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := &config{} + SetArgsForTest(t, tt.args.cliArgs) // set cli args, and clean up after the test + err := loadConfig(config, "my-program", WithDefaultLoadConfigFlag()) require.NoError(t, err) @@ -102,11 +105,14 @@ func Test_loadConfigDefaultTags(t *testing.T) { wantDuration: 2 * time.Second, }, } + _ = tests for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { config := &config{} + SetArgsForTest(t, tt.args.cliArgs) // set cli args, and clean up after the test + err := loadConfig(config, "my-program", WithDefaultLoadConfigFlag()) require.NoError(t, err) @@ -196,6 +202,7 @@ duration = "1m5s" wantDuration: 10 * time.Second, }, } + _ = tests for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -291,6 +298,7 @@ func Test_loadConfigExtraFlags(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { SetArgsForTest(t, []string{"my-program", "--some-string", "hello", "--some-int", "42", "--unknown-flag", "value"}) + err := loadConfig(tt.cfg, "my-program", tt.loadOpts...) require.Error(t, err) assert.Contains(t, err.Error(), "flag provided but not defined: -unknown-flag") @@ -307,6 +315,7 @@ func Test_PrintCorrectUsage(t *testing.T) { } SetArgsForTest(t, []string{"my-program", "--unknown-value", "to_trigger_usage"}) + err := loadConfig(&config{}, "my-program") require.Error(t, err) @@ -353,6 +362,7 @@ func Test_loadConfigDuplicates(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { SetArgsForTest(t, []string{"my-program"}) // no args set + err := loadConfig(tt.cfg, "my-program") if tt.wantError != "" { require.Error(t, err) @@ -366,9 +376,11 @@ func Test_loadConfigDuplicates(t *testing.T) { func SetArgsForTest(t *testing.T, args []string) { oldArgs := os.Args + t.Cleanup(func() { os.Args = oldArgs }) + os.Args = args } @@ -383,6 +395,7 @@ func Test_validate(t *testing.T) { type args struct { config any } + tests := []struct { name string args args diff --git a/tags.go b/tags.go index b80cafa..9921637 100644 --- a/tags.go +++ b/tags.go @@ -90,12 +90,13 @@ func parseTags(tag *reflect.StructTag) *configFieldTags { defaultValue: tag.Get("default"), help: tag.Get("help"), } + alias := tag.Get("alias") if alias != "" { - parts := strings.Split(alias, ",") - for _, part := range parts { - if strings.HasPrefix(part, "-") { - parsed.aliases = append(parsed.aliases, strings.TrimPrefix(part, "-")) + parts := strings.SplitSeq(alias, ",") + for part := range parts { + if after, ok := strings.CutPrefix(part, "-"); ok { + parsed.aliases = append(parsed.aliases, after) } } } @@ -112,17 +113,22 @@ func parseTagsWithFieldNameDefault(tag *reflect.StructTag, fieldName string) *co if isExported && tags.flag == "" { tags.flag = kebab } + if isExported && tags.json == "" { tags.json = strcase.ToLowerCamel(fieldName) } + if isExported && tags.toml == "" { tags.toml = kebab } + if isExported && tags.yaml == "" { tags.yaml = kebab } + if isExported && tags.env == "" { tags.env = strcase.ToScreamingSnake(fieldName) } + return tags } diff --git a/toml.go b/toml.go index fda4f3e..c4a6cf7 100644 --- a/toml.go +++ b/toml.go @@ -63,7 +63,7 @@ func (ms *mapSource) Lookup(name string) (any, bool) { case map[string]any: node = make(map[any]any, len(child)) for k, v := range child { - node[k] = v + node[k] = v //nolint: modernize } case map[any]any: node = child @@ -100,6 +100,7 @@ func (mvs *mapsValueSource) Lookup() (string, bool) { return fmt.Sprintf("%+v", v), true } } + return "", false } diff --git a/validate.go b/validate.go index c2d5b22..785d1e9 100644 --- a/validate.go +++ b/validate.go @@ -10,11 +10,13 @@ import ( func validate(configPointer any) error { configValidator := validator.New(validator.WithRequiredStructEnabled()) + err := configValidator.Struct(configPointer) if err != nil { var validationErrors validator.ValidationErrors if errors.As(err, &validationErrors) { errorMessage := &bytes.Buffer{} + for _, fieldError := range validationErrors { validationTag := fieldError.Tag() if validationTag == "required" { @@ -23,8 +25,10 @@ func validate(configPointer any) error { fmt.Fprintf(errorMessage, "Configuration error: %s - %s\n", fieldError.StructField(), fieldError.ActualTag()) } } + return errors.New(errorMessage.String()) } } + return nil }