diff --git a/cmd/shell.go b/cmd/shell.go index 0934614a3..ef4105500 100644 --- a/cmd/shell.go +++ b/cmd/shell.go @@ -34,6 +34,7 @@ import ( semconv "go.opentelemetry.io/otel/semconv/v1.25.0" "google.golang.org/grpc/grpclog" + "github.com/letsencrypt/boulder/config" "github.com/letsencrypt/boulder/core" blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/strictyaml" @@ -455,6 +456,9 @@ func ValidateJSONConfig(cv *ConfigValidator, in io.Reader) error { } } + // Register custom types for use with existing validation tags. + validate.RegisterCustomTypeFunc(config.DurationCustomTypeFunc, config.Duration{}) + err := decodeJSONStrict(in, cv.Config) if err != nil { return err @@ -497,6 +501,9 @@ func ValidateYAMLConfig(cv *ConfigValidator, in io.Reader) error { } } + // Register custom types for use with existing validation tags. + validate.RegisterCustomTypeFunc(config.DurationCustomTypeFunc, config.Duration{}) + inBytes, err := io.ReadAll(in) if err != nil { return err diff --git a/cmd/shell_test.go b/cmd/shell_test.go index debafd54e..e2e0e7f3f 100644 --- a/cmd/shell_test.go +++ b/cmd/shell_test.go @@ -11,10 +11,12 @@ import ( "testing" "time" + "github.com/prometheus/client_golang/prometheus" + + "github.com/letsencrypt/boulder/config" "github.com/letsencrypt/boulder/core" blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/test" - "github.com/prometheus/client_golang/prometheus" ) var ( @@ -196,9 +198,11 @@ func loadConfigFile(t *testing.T, path string) *os.File { func TestFailedConfigValidation(t *testing.T) { type FooConfig struct { - VitalValue string `yaml:"vitalValue" validate:"required"` - VoluntarilyVoid string `yaml:"voluntarilyVoid"` - VisciouslyVetted string `yaml:"visciouslyVetted" validate:"omitempty,endswith=baz"` + VitalValue string `yaml:"vitalValue" validate:"required"` + VoluntarilyVoid string `yaml:"voluntarilyVoid"` + VisciouslyVetted string `yaml:"visciouslyVetted" validate:"omitempty,endswith=baz"` + VolatileVagary config.Duration `yaml:"volatileVagary" validate:"required,lte=120s"` + VernalVeil config.Duration `yaml:"vernalVeil" validate:"required"` } // Violates 'endswith' tag JSON. @@ -228,6 +232,34 @@ func TestFailedConfigValidation(t *testing.T) { err = ValidateYAMLConfig(&ConfigValidator{&FooConfig{}, nil}, cf) test.AssertError(t, err, "Expected validation error") test.AssertContains(t, err.Error(), "'required'") + + // Violates 'lte' tag JSON for config.Duration type. + cf = loadConfigFile(t, "testdata/3_configDuration_too_darn_big.json") + defer cf.Close() + err = ValidateJSONConfig(&ConfigValidator{&FooConfig{}, nil}, cf) + test.AssertError(t, err, "Expected validation error") + test.AssertContains(t, err.Error(), "'lte'") + + // Violates 'lte' tag JSON for config.Duration type. + cf = loadConfigFile(t, "testdata/3_configDuration_too_darn_big.json") + defer cf.Close() + err = ValidateJSONConfig(&ConfigValidator{&FooConfig{}, nil}, cf) + test.AssertError(t, err, "Expected validation error") + test.AssertContains(t, err.Error(), "'lte'") + + // Incorrect value for the config.Duration type. + cf = loadConfigFile(t, "testdata/4_incorrect_data_for_type.json") + defer cf.Close() + err = ValidateJSONConfig(&ConfigValidator{&FooConfig{}, nil}, cf) + test.AssertError(t, err, "Expected error") + test.AssertContains(t, err.Error(), "missing unit in duration") + + // Incorrect value for the config.Duration type. + cf = loadConfigFile(t, "testdata/4_incorrect_data_for_type.yaml") + defer cf.Close() + err = ValidateYAMLConfig(&ConfigValidator{&FooConfig{}, nil}, cf) + test.AssertError(t, err, "Expected error") + test.AssertContains(t, err.Error(), "missing unit in duration") } func TestFailExit(t *testing.T) { diff --git a/cmd/testdata/3_configDuration_too_darn_big.json b/cmd/testdata/3_configDuration_too_darn_big.json new file mode 100644 index 000000000..0b108edb7 --- /dev/null +++ b/cmd/testdata/3_configDuration_too_darn_big.json @@ -0,0 +1,6 @@ +{ + "vitalValue": "Gotcha", + "voluntarilyVoid": "Not used", + "visciouslyVetted": "Whateverbaz", + "volatileVagary": "121s" +} diff --git a/cmd/testdata/4_incorrect_data_for_type.json b/cmd/testdata/4_incorrect_data_for_type.json new file mode 100644 index 000000000..5805d59ee --- /dev/null +++ b/cmd/testdata/4_incorrect_data_for_type.json @@ -0,0 +1,7 @@ +{ + "vitalValue": "Gotcha", + "voluntarilyVoid": "Not used", + "visciouslyVetted": "Whateverbaz", + "volatileVagary": "120s", + "vernalVeil": "60" +} diff --git a/cmd/testdata/4_incorrect_data_for_type.yaml b/cmd/testdata/4_incorrect_data_for_type.yaml new file mode 100644 index 000000000..02093be82 --- /dev/null +++ b/cmd/testdata/4_incorrect_data_for_type.yaml @@ -0,0 +1,5 @@ +vitalValue: "Gotcha" +voluntarilyVoid: "Not used" +visciouslyVetted: "Whateverbaz" +volatileVagary: "120s" +vernalVeil: "60" diff --git a/config/duration.go b/config/duration.go index c97eeb486..90cb2277d 100644 --- a/config/duration.go +++ b/config/duration.go @@ -3,15 +3,27 @@ package config import ( "encoding/json" "errors" + "reflect" "time" ) -// Duration is just an alias for time.Duration that allows -// serialization to YAML as well as JSON. +// Duration is custom type embedding a time.Duration which allows defining +// methods such as serialization to YAML or JSON. type Duration struct { time.Duration `validate:"required"` } +// DurationCustomTypeFunc enables registration of our custom config.Duration +// type as a time.Duration and performing validation on the configured value +// using the standard suite of validation functions. +func DurationCustomTypeFunc(field reflect.Value) interface{} { + if c, ok := field.Interface().(Duration); ok { + return c.Duration + } + + return reflect.Invalid +} + // ErrDurationMustBeString is returned when a non-string value is // presented to be deserialized as a ConfigDuration var ErrDurationMustBeString = errors.New("cannot JSON unmarshal something other than a string into a ConfigDuration")