From 1a3df54c046e41ccbbc3af374baf56feb52a16fc Mon Sep 17 00:00:00 2001 From: Devon Steenberg Date: Wed, 23 Apr 2025 13:59:51 +1200 Subject: [PATCH] fix(govalidator): replace govalidator dependency [BE-11574] (#673) --- api/git/update/validate.go | 4 +- api/git/validate.go | 6 +- .../customtemplates/customtemplate_create.go | 4 +- .../customtemplates/customtemplate_update.go | 5 +- api/http/handler/edgejobs/edgejob_create.go | 7 +- api/http/handler/edgejobs/edgejob_update.go | 5 +- .../edgestacks/edgestack_create_git.go | 4 +- .../handler/gitops/git_repo_file_preview.go | 5 +- api/http/handler/settings/settings_update.go | 8 +- .../handler/stacks/create_compose_stack.go | 4 +- .../handler/stacks/create_kubernetes_stack.go | 6 +- api/http/handler/stacks/create_swarm_stack.go | 4 +- .../handler/users/user_create_access_token.go | 7 +- api/http/handler/websocket/attach.go | 4 +- api/http/handler/websocket/exec.go | 4 +- go.mod | 2 +- pkg/validate/validate.go | 111 +++++ pkg/validate/validate_test.go | 424 ++++++++++++++++++ 18 files changed, 571 insertions(+), 43 deletions(-) create mode 100644 pkg/validate/validate.go create mode 100644 pkg/validate/validate_test.go diff --git a/api/git/update/validate.go b/api/git/update/validate.go index c1b7364ce..66805895d 100644 --- a/api/git/update/validate.go +++ b/api/git/update/validate.go @@ -3,9 +3,9 @@ package update import ( "time" - "github.com/asaskevich/govalidator" portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/pkg/validate" ) func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error { @@ -17,7 +17,7 @@ func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error return httperrors.NewInvalidPayloadError("Webhook or Interval must be provided") } - if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) { + if autoUpdate.Webhook != "" && !validate.IsUUID(autoUpdate.Webhook) { return httperrors.NewInvalidPayloadError("invalid Webhook format") } diff --git a/api/git/validate.go b/api/git/validate.go index 8fc04abf6..1304494dd 100644 --- a/api/git/validate.go +++ b/api/git/validate.go @@ -1,19 +1,17 @@ package git import ( - "github.com/asaskevich/govalidator" - gittypes "github.com/portainer/portainer/api/git/types" httperrors "github.com/portainer/portainer/api/http/errors" + "github.com/portainer/portainer/pkg/validate" ) func ValidateRepoConfig(repoConfig *gittypes.RepoConfig) error { - if len(repoConfig.URL) == 0 || !govalidator.IsURL(repoConfig.URL) { + if len(repoConfig.URL) == 0 || !validate.IsURL(repoConfig.URL) { return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format") } return ValidateRepoAuthentication(repoConfig.Authentication) - } func ValidateRepoAuthentication(auth *gittypes.GitAuthentication) error { diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index 0f9c8f1a3..104ea90a4 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -16,8 +16,8 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" - "github.com/asaskevich/govalidator" "github.com/rs/zerolog/log" "github.com/segmentio/encoding/json" ) @@ -228,7 +228,7 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request) if len(payload.Description) == 0 { return errors.New("Invalid custom template description") } - if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { return errors.New("Invalid repository URL. Must correspond to a valid URL format") } if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) { diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go index 80c42b6fa..f14d228f3 100644 --- a/api/http/handler/customtemplates/customtemplate_update.go +++ b/api/http/handler/customtemplates/customtemplate_update.go @@ -15,8 +15,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" - - "github.com/asaskevich/govalidator" + "github.com/portainer/portainer/pkg/validate" ) type customTemplateUpdatePayload struct { @@ -170,7 +169,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ customTemplate.EdgeTemplate = payload.EdgeTemplate if payload.RepositoryURL != "" { - if !govalidator.IsURL(payload.RepositoryURL) { + if !validate.IsURL(payload.RepositoryURL) { return httperror.BadRequest("Invalid repository URL. Must correspond to a valid URL format", err) } diff --git a/api/http/handler/edgejobs/edgejob_create.go b/api/http/handler/edgejobs/edgejob_create.go index 0f7bff73d..dd6b4d5df 100644 --- a/api/http/handler/edgejobs/edgejob_create.go +++ b/api/http/handler/edgejobs/edgejob_create.go @@ -15,8 +15,7 @@ import ( "github.com/portainer/portainer/api/internal/endpointutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" - - "github.com/asaskevich/govalidator" + "github.com/portainer/portainer/pkg/validate" ) type edgeJobBasePayload struct { @@ -53,7 +52,7 @@ func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) er return errors.New("invalid Edge job name") } - if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) { + if !validate.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) { return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]") } @@ -136,7 +135,7 @@ func (payload *edgeJobCreateFromFilePayload) Validate(r *http.Request) error { return errors.New("invalid Edge job name") } - if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + if !validate.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]") } payload.Name = name diff --git a/api/http/handler/edgejobs/edgejob_update.go b/api/http/handler/edgejobs/edgejob_update.go index 468fbb8b7..6f2b8e382 100644 --- a/api/http/handler/edgejobs/edgejob_update.go +++ b/api/http/handler/edgejobs/edgejob_update.go @@ -14,8 +14,7 @@ import ( "github.com/portainer/portainer/api/internal/endpointutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" - - "github.com/asaskevich/govalidator" + "github.com/portainer/portainer/pkg/validate" ) type edgeJobUpdatePayload struct { @@ -28,7 +27,7 @@ type edgeJobUpdatePayload struct { } func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error { - if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { + if payload.Name != nil && !validate.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) { return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]") } diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go index 88e4bda79..2da816481 100644 --- a/api/http/handler/edgestacks/edgestack_create_git.go +++ b/api/http/handler/edgestacks/edgestack_create_git.go @@ -11,8 +11,8 @@ import ( httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/pkg/edge" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/validate" - "github.com/asaskevich/govalidator" "github.com/pkg/errors" ) @@ -59,7 +59,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number") } - if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format") } diff --git a/api/http/handler/gitops/git_repo_file_preview.go b/api/http/handler/gitops/git_repo_file_preview.go index 28e8fafec..1eaa52716 100644 --- a/api/http/handler/gitops/git_repo_file_preview.go +++ b/api/http/handler/gitops/git_repo_file_preview.go @@ -9,8 +9,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" - - "github.com/asaskevich/govalidator" + "github.com/portainer/portainer/pkg/validate" ) type fileResponse struct { @@ -29,7 +28,7 @@ type repositoryFilePreviewPayload struct { } func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error { - if len(payload.Repository) == 0 || !govalidator.IsURL(payload.Repository) { + if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) { return errors.New("invalid repository URL. Must correspond to a valid URL format") } diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 0b36dbc62..98da8da7d 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -14,8 +14,8 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" - "github.com/asaskevich/govalidator" "github.com/pkg/errors" "golang.org/x/oauth2" ) @@ -62,15 +62,15 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error { return errors.New("Invalid authentication method value. Value must be one of: 1 (internal), 2 (LDAP/AD) or 3 (OAuth)") } - if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) { + if payload.LogoURL != nil && *payload.LogoURL != "" && !validate.IsURL(*payload.LogoURL) { return errors.New("Invalid logo URL. Must correspond to a valid URL format") } - if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) { + if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !validate.IsURL(*payload.TemplatesURL) { return errors.New("Invalid external templates URL. Must correspond to a valid URL format") } - if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !govalidator.IsURL(*payload.HelmRepositoryURL) { + if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !validate.IsURL(*payload.HelmRepositoryURL) { return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format") } diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index dce39337a..fc5bed1ff 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -14,8 +14,8 @@ import ( "github.com/portainer/portainer/api/stacks/stackutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/validate" - "github.com/asaskevich/govalidator" "github.com/pkg/errors" "github.com/rs/zerolog/log" ) @@ -205,7 +205,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e if len(payload.Name) == 0 { return errors.New("Invalid stack name") } - if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { return errors.New("Invalid repository URL. Must correspond to a valid URL format") } if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 397ccfec2..f1a142e6b 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -15,8 +15,8 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" + "github.com/portainer/portainer/pkg/validate" - "github.com/asaskevich/govalidator" "github.com/pkg/errors" ) @@ -96,7 +96,7 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro } func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error { - if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) { + if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) { return errors.New("Invalid repository URL. Must correspond to a valid URL format") } @@ -112,7 +112,7 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error { } func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error { - if len(payload.ManifestURL) == 0 || !govalidator.IsURL(payload.ManifestURL) { + if len(payload.ManifestURL) == 0 || !validate.IsURL(payload.ManifestURL) { return errors.New("Invalid manifest URL") } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 4603b6d6b..e10d23f2f 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -11,8 +11,8 @@ import ( "github.com/portainer/portainer/api/stacks/stackutils" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + valid "github.com/portainer/portainer/pkg/validate" - "github.com/asaskevich/govalidator" "github.com/pkg/errors" ) @@ -142,7 +142,7 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err if len(payload.SwarmID) == 0 { return errors.New("Invalid Swarm ID") } - if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) { + if len(payload.RepositoryURL) == 0 || !valid.IsURL(payload.RepositoryURL) { return errors.New("Invalid repository URL. Must correspond to a valid URL format") } if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 { diff --git a/api/http/handler/users/user_create_access_token.go b/api/http/handler/users/user_create_access_token.go index 673c0af76..aa10f6fe0 100644 --- a/api/http/handler/users/user_create_access_token.go +++ b/api/http/handler/users/user_create_access_token.go @@ -11,8 +11,7 @@ import ( httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" - - "github.com/asaskevich/govalidator" + "github.com/portainer/portainer/pkg/validate" ) type userAccessTokenCreatePayload struct { @@ -24,10 +23,10 @@ func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error { if len(payload.Description) == 0 { return errors.New("invalid description: cannot be empty") } - if govalidator.HasWhitespaceOnly(payload.Description) { + if validate.HasWhitespaceOnly(payload.Description) { return errors.New("invalid description: cannot contain only whitespaces") } - if govalidator.MinStringLength(payload.Description, "128") { + if validate.MinStringLength(payload.Description, 128) { return errors.New("invalid description: cannot be longer than 128 characters") } return nil diff --git a/api/http/handler/websocket/attach.go b/api/http/handler/websocket/attach.go index d0cb7746f..96e228418 100644 --- a/api/http/handler/websocket/attach.go +++ b/api/http/handler/websocket/attach.go @@ -9,8 +9,8 @@ import ( "github.com/portainer/portainer/api/ws" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/validate" - "github.com/asaskevich/govalidator" "github.com/gorilla/websocket" ) @@ -38,7 +38,7 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request) if err != nil { return httperror.BadRequest("Invalid query parameter: id", err) } - if !govalidator.IsHexadecimal(attachID) { + if !validate.IsHexadecimal(attachID) { return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err) } diff --git a/api/http/handler/websocket/exec.go b/api/http/handler/websocket/exec.go index ab04b0702..aef5861c8 100644 --- a/api/http/handler/websocket/exec.go +++ b/api/http/handler/websocket/exec.go @@ -8,8 +8,8 @@ import ( "github.com/portainer/portainer/api/ws" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/portainer/portainer/pkg/libhttp/request" + "github.com/portainer/portainer/pkg/validate" - "github.com/asaskevich/govalidator" "github.com/gorilla/websocket" "github.com/segmentio/encoding/json" ) @@ -42,7 +42,7 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h if err != nil { return httperror.BadRequest("Invalid query parameter: id", err) } - if !govalidator.IsHexadecimal(execID) { + if !validate.IsHexadecimal(execID) { return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err) } diff --git a/go.mod b/go.mod index 64c594902..45f0eb750 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/Microsoft/go-winio v0.6.2 github.com/VictoriaMetrics/fastcache v1.12.0 - github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/aws/aws-sdk-go-v2 v1.24.1 github.com/aws/aws-sdk-go-v2/credentials v1.16.16 github.com/aws/aws-sdk-go-v2/service/ecr v1.24.1 @@ -85,6 +84,7 @@ require ( github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 // indirect github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/config v1.26.6 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect diff --git a/pkg/validate/validate.go b/pkg/validate/validate.go new file mode 100644 index 000000000..3683941cf --- /dev/null +++ b/pkg/validate/validate.go @@ -0,0 +1,111 @@ +package validate + +import ( + "net" + "net/url" + "regexp" + "strings" + "unicode/utf8" +) + +const ( + minURLRuneCount = 3 + maxURLRuneCount = 2083 + + ipPattern = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))` + urlSchemaPattern = `((ftp|tcp|udp|wss?|https?):\/\/)` + urlUsernamePattern = `(\S+(:\S*)?@)` + urlPathPattern = `((\/|\?|#)[^\s]*)` + urlPortPattern = `(:(\d{1,5}))` + urlIPPattern = `([1-9]\d?|1\d\d|2[01]\d|22[0-3]|24\d|25[0-5])(\.(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-5]))` + urlSubdomainPattern = `((www\.)|([a-zA-Z0-9]+([-_\.]?[a-zA-Z0-9])*[a-zA-Z0-9]\.[a-zA-Z0-9]+))` + urlPattern = `^` + urlSchemaPattern + `?` + urlUsernamePattern + `?` + `((` + urlIPPattern + `|(\[` + ipPattern + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + urlSubdomainPattern + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + urlPortPattern + `?` + urlPathPattern + `?$` +) + +var ( + urlRegex = regexp.MustCompile(urlPattern) + uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + hexadecimalRegex = regexp.MustCompile(`^[0-9a-fA-F]+$`) + whitespaceRegex = regexp.MustCompile(`^[[:space:]]+$`) + dnsNameRegex = regexp.MustCompile(`^([a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62}){1}(\.[a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62})*[\._]?$`) +) + +func IsURL(urlString string) bool { + if urlString == "" || + utf8.RuneCountInString(urlString) >= maxURLRuneCount || + len(urlString) <= minURLRuneCount || + strings.HasPrefix(urlString, ".") { + return false + } + + strTemp := urlString + if strings.Contains(urlString, ":") && !strings.Contains(urlString, "://") { + // support no indicated urlscheme but with colon for port number + // http:// is appended so url.Parse will succeed, strTemp used so it does not impact rxURL.MatchString + strTemp = "http://" + urlString + } + + u, err := url.Parse(strTemp) + if err != nil { + return false + } + + if strings.HasPrefix(u.Host, ".") { + return false + } + if u.Host == "" && (u.Path != "" && !strings.Contains(u.Path, ".")) { + return false + } + + return urlRegex.MatchString(urlString) +} + +func IsUUID(uuidString string) bool { + return uuidRegex.MatchString(uuidString) +} + +func IsHexadecimal(hexString string) bool { + return hexadecimalRegex.MatchString(hexString) +} + +func HasWhitespaceOnly(s string) bool { + return len(s) > 0 && whitespaceRegex.MatchString(s) +} + +func MinStringLength(s string, len int) bool { + return utf8.RuneCountInString(s) >= len +} + +func Matches(s, pattern string) bool { + match, err := regexp.MatchString(pattern, s) + return err == nil && match +} + +func IsNonPositive(f float64) bool { + return f <= 0 +} + +func InRange(val, left, right float64) bool { + if left > right { + left, right = right, left + } + + return val >= left && val <= right +} + +func IsHost(s string) bool { + return IsIP(s) || IsDNSName(s) +} + +func IsIP(s string) bool { + return net.ParseIP(s) != nil +} + +func IsDNSName(s string) bool { + if s == "" || len(strings.ReplaceAll(s, ".", "")) > 255 { + // constraints already violated + return false + } + + return !IsIP(s) && dnsNameRegex.MatchString(s) +} diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go new file mode 100644 index 000000000..e8b62288e --- /dev/null +++ b/pkg/validate/validate_test.go @@ -0,0 +1,424 @@ +package validate + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_IsURL(t *testing.T) { + testCases := []struct { + name string + url string + expectedResult bool + }{ + { + name: "simple url", + url: "https://google.com", + expectedResult: true, + }, + { + name: "no schema", + url: "google.com", + expectedResult: true, + }, + { + name: "path", + url: "https://google.com/some/thing", + expectedResult: true, + }, + { + name: "query params", + url: "https://google.com/some/thing?a=5&b=6", + expectedResult: true, + }, + { + name: "invalid", + url: "google", + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsURL(tc.url) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_IsUUID(t *testing.T) { + testCases := []struct { + name string + uuid string + expectedResult bool + }{ + { + name: "empty", + uuid: "", + expectedResult: false, + }, + { + name: "version 3 UUID", + uuid: "060507eb-3b9a-362e-b850-d5f065eea403", + expectedResult: true, + }, + { + name: "version 4 UUID", + uuid: "63e695ee-48a9-498a-98b3-9472ff75e09f", + expectedResult: true, + }, + { + name: "version 5 UUID", + uuid: "5daabcd8-f17e-568c-aa6f-da9d92c7032c", + expectedResult: true, + }, + { + name: "text", + uuid: "something like this", + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsUUID(tc.uuid) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_IsHexadecimal(t *testing.T) { + testCases := []struct { + name string + hex string + expectedResult bool + }{ + { + name: "empty", + hex: "", + expectedResult: false, + }, + { + name: "hex", + hex: "48656C6C6F20736F6D657468696E67", + expectedResult: true, + }, + { + name: "text", + hex: "something like this", + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsHexadecimal(tc.hex) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_HasWhitespaceOnly(t *testing.T) { + testCases := []struct { + name string + s string + expectedResult bool + }{ + { + name: "empty", + s: "", + expectedResult: false, + }, + { + name: "space", + s: " ", + expectedResult: true, + }, + { + name: "tab", + s: "\t", + expectedResult: true, + }, + { + name: "text", + s: "something like this", + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := HasWhitespaceOnly(tc.s) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_MinStringLength(t *testing.T) { + testCases := []struct { + name string + s string + len int + expectedResult bool + }{ + { + name: "empty + zero len", + s: "", + len: 0, + expectedResult: true, + }, + { + name: "empty + non zero len", + s: "", + len: 10, + expectedResult: false, + }, + { + name: "long text + non zero len", + s: "something else", + len: 10, + expectedResult: true, + }, + { + name: "multibyte characters - enough", + s: "X生", + len: 2, + expectedResult: true, + }, + { + name: "multibyte characters - not enough", + s: "X生", + len: 3, + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := MinStringLength(tc.s, tc.len) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_Matches(t *testing.T) { + testCases := []struct { + name string + s string + pattern string + expectedResult bool + }{ + { + name: "empty", + s: "", + pattern: "", + expectedResult: true, + }, + { + name: "space", + s: "something else", + pattern: " ", + expectedResult: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := Matches(tc.s, tc.pattern) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_IsNonPositive(t *testing.T) { + testCases := []struct { + name string + f float64 + expectedResult bool + }{ + { + name: "zero", + f: 0, + expectedResult: true, + }, + { + name: "positive", + f: 1, + expectedResult: false, + }, + { + name: "negative", + f: -1, + expectedResult: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsNonPositive(tc.f) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_InRange(t *testing.T) { + testCases := []struct { + name string + f float64 + left float64 + right float64 + expectedResult bool + }{ + { + name: "zero", + f: 0, + left: 0, + right: 0, + expectedResult: true, + }, + { + name: "equal left", + f: 1, + left: 1, + right: 2, + expectedResult: true, + }, + { + name: "equal right", + f: 2, + left: 1, + right: 2, + expectedResult: true, + }, + { + name: "above", + f: 3, + left: 1, + right: 2, + expectedResult: false, + }, + { + name: "below", + f: 0, + left: 1, + right: 2, + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := InRange(tc.f, tc.left, tc.right) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_IsHost(t *testing.T) { + testCases := []struct { + name string + s string + expectedResult bool + }{ + { + name: "empty", + s: "", + expectedResult: false, + }, + { + name: "ip address", + s: "192.168.1.1", + expectedResult: true, + }, + { + name: "hostname", + s: "google.com", + expectedResult: true, + }, + { + name: "text", + s: "Something like this", + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsHost(tc.s) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_IsIP(t *testing.T) { + testCases := []struct { + name string + s string + expectedResult bool + }{ + { + name: "empty", + s: "", + expectedResult: false, + }, + { + name: "ip address", + s: "192.168.1.1", + expectedResult: true, + }, + { + name: "hostname", + s: "google.com", + expectedResult: false, + }, + { + name: "text", + s: "Something like this", + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsIP(tc.s) + require.Equal(t, tc.expectedResult, result) + }) + } +} + +func Test_IsDNSName(t *testing.T) { + testCases := []struct { + name string + s string + expectedResult bool + }{ + { + name: "empty", + s: "", + expectedResult: false, + }, + { + name: "ip address", + s: "192.168.1.1", + expectedResult: false, + }, + { + name: "hostname", + s: "google.com", + expectedResult: true, + }, + { + name: "text", + s: "Something like this", + expectedResult: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := IsDNSName(tc.s) + require.Equal(t, tc.expectedResult, result) + }) + } +}