summaryrefslogtreecommitdiffstats
path: root/modules/validation
diff options
context:
space:
mode:
authorDaniel Baumann <daniel@debian.org>2024-10-18 20:33:49 +0200
committerDaniel Baumann <daniel@debian.org>2024-12-12 23:57:56 +0100
commite68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch)
tree97775d6c13b0f416af55314eb6a89ef792474615 /modules/validation
parentInitial commit. (diff)
downloadforgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz
forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r--modules/validation/binding.go209
-rw-r--r--modules/validation/binding_test.go62
-rw-r--r--modules/validation/glob_pattern_test.go61
-rw-r--r--modules/validation/helpers.go136
-rw-r--r--modules/validation/helpers_test.go216
-rw-r--r--modules/validation/refname_test.go265
-rw-r--r--modules/validation/regex_pattern_test.go59
-rw-r--r--modules/validation/validatable.go84
-rw-r--r--modules/validation/validatable_test.go69
-rw-r--r--modules/validation/validurl_test.go110
10 files changed, 1271 insertions, 0 deletions
diff --git a/modules/validation/binding.go b/modules/validation/binding.go
new file mode 100644
index 0000000..cb0a506
--- /dev/null
+++ b/modules/validation/binding.go
@@ -0,0 +1,209 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/auth"
+ "code.gitea.io/gitea/modules/git"
+
+ "gitea.com/go-chi/binding"
+ "github.com/gobwas/glob"
+)
+
+const (
+ // ErrGitRefName is git reference name error
+ ErrGitRefName = "GitRefNameError"
+ // ErrGlobPattern is returned when glob pattern is invalid
+ ErrGlobPattern = "GlobPattern"
+ // ErrRegexPattern is returned when a regex pattern is invalid
+ ErrRegexPattern = "RegexPattern"
+ // ErrUsername is username error
+ ErrUsername = "UsernameError"
+ // ErrInvalidGroupTeamMap is returned when a group team mapping is invalid
+ ErrInvalidGroupTeamMap = "InvalidGroupTeamMap"
+)
+
+// AddBindingRules adds additional binding rules
+func AddBindingRules() {
+ addGitRefNameBindingRule()
+ addValidURLBindingRule()
+ addValidSiteURLBindingRule()
+ addGlobPatternRule()
+ addRegexPatternRule()
+ addGlobOrRegexPatternRule()
+ addUsernamePatternRule()
+ addValidGroupTeamMapRule()
+}
+
+func addGitRefNameBindingRule() {
+ // Git refname validation rule
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return strings.HasPrefix(rule, "GitRefName")
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+
+ if !git.IsValidRefPattern(str) {
+ errs.Add([]string{name}, ErrGitRefName, "GitRefName")
+ return false, errs
+ }
+ return true, errs
+ },
+ })
+}
+
+func addValidURLBindingRule() {
+ // URL validation rule
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return strings.HasPrefix(rule, "ValidUrl")
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+ if len(str) != 0 && !IsValidURL(str) {
+ errs.Add([]string{name}, binding.ERR_URL, "Url")
+ return false, errs
+ }
+
+ return true, errs
+ },
+ })
+}
+
+func addValidSiteURLBindingRule() {
+ // URL validation rule
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return strings.HasPrefix(rule, "ValidSiteUrl")
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+ if len(str) != 0 && !IsValidSiteURL(str) {
+ errs.Add([]string{name}, binding.ERR_URL, "Url")
+ return false, errs
+ }
+
+ return true, errs
+ },
+ })
+}
+
+func addGlobPatternRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return rule == "GlobPattern"
+ },
+ IsValid: globPatternValidator,
+ })
+}
+
+func globPatternValidator(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+
+ if len(str) != 0 {
+ if _, err := glob.Compile(str); err != nil {
+ errs.Add([]string{name}, ErrGlobPattern, err.Error())
+ return false, errs
+ }
+ }
+
+ return true, errs
+}
+
+func addRegexPatternRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return rule == "RegexPattern"
+ },
+ IsValid: regexPatternValidator,
+ })
+}
+
+func regexPatternValidator(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+
+ if _, err := regexp.Compile(str); err != nil {
+ errs.Add([]string{name}, ErrRegexPattern, err.Error())
+ return false, errs
+ }
+
+ return true, errs
+}
+
+func addGlobOrRegexPatternRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return rule == "GlobOrRegexPattern"
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := strings.TrimSpace(fmt.Sprintf("%v", val))
+
+ if len(str) >= 2 && strings.HasPrefix(str, "/") && strings.HasSuffix(str, "/") {
+ return regexPatternValidator(errs, name, str[1:len(str)-1])
+ }
+ return globPatternValidator(errs, name, val)
+ },
+ })
+}
+
+func addUsernamePatternRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return rule == "Username"
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ str := fmt.Sprintf("%v", val)
+ if !IsValidUsername(str) {
+ errs.Add([]string{name}, ErrUsername, "invalid username")
+ return false, errs
+ }
+ return true, errs
+ },
+ })
+}
+
+func addValidGroupTeamMapRule() {
+ binding.AddRule(&binding.Rule{
+ IsMatch: func(rule string) bool {
+ return strings.HasPrefix(rule, "ValidGroupTeamMap")
+ },
+ IsValid: func(errs binding.Errors, name string, val any) (bool, binding.Errors) {
+ _, err := auth.UnmarshalGroupTeamMapping(fmt.Sprintf("%v", val))
+ if err != nil {
+ errs.Add([]string{name}, ErrInvalidGroupTeamMap, err.Error())
+ return false, errs
+ }
+
+ return true, errs
+ },
+ })
+}
+
+func portOnly(hostport string) string {
+ colon := strings.IndexByte(hostport, ':')
+ if colon == -1 {
+ return ""
+ }
+ if i := strings.Index(hostport, "]:"); i != -1 {
+ return hostport[i+len("]:"):]
+ }
+ if strings.Contains(hostport, "]") {
+ return ""
+ }
+ return hostport[colon+len(":"):]
+}
+
+func validPort(p string) bool {
+ for _, r := range []byte(p) {
+ if r < '0' || r > '9' {
+ return false
+ }
+ }
+ return true
+}
diff --git a/modules/validation/binding_test.go b/modules/validation/binding_test.go
new file mode 100644
index 0000000..01ff4e3
--- /dev/null
+++ b/modules/validation/binding_test.go
@@ -0,0 +1,62 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "gitea.com/go-chi/binding"
+ chi "github.com/go-chi/chi/v5"
+ "github.com/stretchr/testify/assert"
+)
+
+const (
+ testRoute = "/test"
+)
+
+type (
+ validationTestCase struct {
+ description string
+ data any
+ expectedErrors binding.Errors
+ }
+
+ TestForm struct {
+ BranchName string `form:"BranchName" binding:"GitRefName"`
+ URL string `form:"ValidUrl" binding:"ValidUrl"`
+ GlobPattern string `form:"GlobPattern" binding:"GlobPattern"`
+ RegexPattern string `form:"RegexPattern" binding:"RegexPattern"`
+ }
+)
+
+func performValidationTest(t *testing.T, testCase validationTestCase) {
+ httpRecorder := httptest.NewRecorder()
+ m := chi.NewRouter()
+
+ m.Post(testRoute, func(resp http.ResponseWriter, req *http.Request) {
+ actual := binding.Validate(req, testCase.data)
+ // see https://github.com/stretchr/testify/issues/435
+ if actual == nil {
+ actual = binding.Errors{}
+ }
+
+ assert.Equal(t, testCase.expectedErrors, actual)
+ })
+
+ req, err := http.NewRequest("POST", testRoute, nil)
+ if err != nil {
+ panic(err)
+ }
+ req.Header.Add("Content-Type", "x-www-form-urlencoded")
+ m.ServeHTTP(httpRecorder, req)
+
+ switch httpRecorder.Code {
+ case http.StatusNotFound:
+ panic("Routing is messed up in test fixture (got 404): check methods and paths")
+ case http.StatusInternalServerError:
+ panic("Something bad happened on '" + testCase.description + "'")
+ }
+}
diff --git a/modules/validation/glob_pattern_test.go b/modules/validation/glob_pattern_test.go
new file mode 100644
index 0000000..1bf622e
--- /dev/null
+++ b/modules/validation/glob_pattern_test.go
@@ -0,0 +1,61 @@
+// Copyright 2019 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "gitea.com/go-chi/binding"
+ "github.com/gobwas/glob"
+)
+
+func getGlobPatternErrorString(pattern string) string {
+ // It would be unwise to rely on that glob
+ // compilation errors don't ever change.
+ if _, err := glob.Compile(pattern); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
+var globValidationTestCases = []validationTestCase{
+ {
+ description: "Empty glob pattern",
+ data: TestForm{
+ GlobPattern: "",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Valid glob",
+ data: TestForm{
+ GlobPattern: "{master,release*}",
+ },
+ expectedErrors: binding.Errors{},
+ },
+
+ {
+ description: "Invalid glob",
+ data: TestForm{
+ GlobPattern: "[a-",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"GlobPattern"},
+ Classification: ErrGlobPattern,
+ Message: getGlobPatternErrorString("[a-"),
+ },
+ },
+ },
+}
+
+func Test_GlobPatternValidation(t *testing.T) {
+ AddBindingRules()
+
+ for _, testCase := range globValidationTestCases {
+ t.Run(testCase.description, func(t *testing.T) {
+ performValidationTest(t, testCase)
+ })
+ }
+}
diff --git a/modules/validation/helpers.go b/modules/validation/helpers.go
new file mode 100644
index 0000000..567ad86
--- /dev/null
+++ b/modules/validation/helpers.go
@@ -0,0 +1,136 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "net"
+ "net/url"
+ "regexp"
+ "strings"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/gobwas/glob"
+)
+
+var externalTrackerRegex = regexp.MustCompile(`({?)(?:user|repo|index)+?(}?)`)
+
+func isLoopbackIP(ip string) bool {
+ return net.ParseIP(ip).IsLoopback()
+}
+
+// IsValidURL checks if URL is valid
+func IsValidURL(uri string) bool {
+ if u, err := url.ParseRequestURI(uri); err != nil ||
+ (u.Scheme != "http" && u.Scheme != "https") ||
+ !validPort(portOnly(u.Host)) {
+ return false
+ }
+
+ return true
+}
+
+// IsValidSiteURL checks if URL is valid
+func IsValidSiteURL(uri string) bool {
+ u, err := url.ParseRequestURI(uri)
+ if err != nil {
+ return false
+ }
+
+ if !validPort(portOnly(u.Host)) {
+ return false
+ }
+
+ for _, scheme := range setting.Service.ValidSiteURLSchemes {
+ if scheme == u.Scheme {
+ return true
+ }
+ }
+ return false
+}
+
+// IsEmailDomainListed checks whether the domain of an email address
+// matches a list of domains
+func IsEmailDomainListed(globs []glob.Glob, email string) bool {
+ if len(globs) == 0 {
+ return false
+ }
+
+ n := strings.LastIndex(email, "@")
+ if n <= 0 {
+ return false
+ }
+
+ domain := strings.ToLower(email[n+1:])
+
+ for _, g := range globs {
+ if g.Match(domain) {
+ return true
+ }
+ }
+
+ return false
+}
+
+// IsAPIURL checks if URL is current Gitea instance API URL
+func IsAPIURL(uri string) bool {
+ return strings.HasPrefix(strings.ToLower(uri), strings.ToLower(setting.AppURL+"api"))
+}
+
+// IsValidExternalURL checks if URL is valid external URL
+func IsValidExternalURL(uri string) bool {
+ if !IsValidURL(uri) || IsAPIURL(uri) {
+ return false
+ }
+
+ u, err := url.ParseRequestURI(uri)
+ if err != nil {
+ return false
+ }
+
+ // Currently check only if not loopback IP is provided to keep compatibility
+ if isLoopbackIP(u.Hostname()) || strings.ToLower(u.Hostname()) == "localhost" {
+ return false
+ }
+
+ // TODO: Later it should be added to allow local network IP addresses
+ // only if allowed by special setting
+
+ return true
+}
+
+// IsValidExternalTrackerURLFormat checks if URL matches required syntax for external trackers
+func IsValidExternalTrackerURLFormat(uri string) bool {
+ if !IsValidExternalURL(uri) {
+ return false
+ }
+
+ // check for typoed variables like /{index/ or /[repo}
+ for _, match := range externalTrackerRegex.FindAllStringSubmatch(uri, -1) {
+ if (match[1] == "{" || match[2] == "}") && (match[1] != "{" || match[2] != "}") {
+ return false
+ }
+ }
+
+ return true
+}
+
+var (
+ validUsernamePatternWithDots = regexp.MustCompile(`^[\da-zA-Z][-.\w]*$`)
+ validUsernamePatternWithoutDots = regexp.MustCompile(`^[\da-zA-Z][-\w]*$`)
+
+ // No consecutive or trailing non-alphanumeric chars, catches both cases
+ invalidUsernamePattern = regexp.MustCompile(`[-._]{2,}|[-._]$`)
+)
+
+// IsValidUsername checks if username is valid
+func IsValidUsername(name string) bool {
+ // It is difficult to find a single pattern that is both readable and effective,
+ // but it's easier to use positive and negative checks.
+ if setting.Service.AllowDotsInUsernames {
+ return validUsernamePatternWithDots.MatchString(name) && !invalidUsernamePattern.MatchString(name)
+ }
+
+ return validUsernamePatternWithoutDots.MatchString(name) && !invalidUsernamePattern.MatchString(name)
+}
diff --git a/modules/validation/helpers_test.go b/modules/validation/helpers_test.go
new file mode 100644
index 0000000..a1bdf2a
--- /dev/null
+++ b/modules/validation/helpers_test.go
@@ -0,0 +1,216 @@
+// Copyright 2018 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/setting"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_IsValidURL(t *testing.T) {
+ cases := []struct {
+ description string
+ url string
+ valid bool
+ }{
+ {
+ description: "Empty URL",
+ url: "",
+ valid: false,
+ },
+ {
+ description: "Loopback IPv4 URL",
+ url: "http://127.0.1.1:5678/",
+ valid: true,
+ },
+ {
+ description: "Loopback IPv6 URL",
+ url: "https://[::1]/",
+ valid: true,
+ },
+ {
+ description: "Missing semicolon after schema",
+ url: "http//meh/",
+ valid: false,
+ },
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.description, func(t *testing.T) {
+ assert.Equal(t, testCase.valid, IsValidURL(testCase.url))
+ })
+ }
+}
+
+func Test_IsValidExternalURL(t *testing.T) {
+ setting.AppURL = "https://try.gitea.io/"
+
+ cases := []struct {
+ description string
+ url string
+ valid bool
+ }{
+ {
+ description: "Current instance URL",
+ url: "https://try.gitea.io/test",
+ valid: true,
+ },
+ {
+ description: "Loopback IPv4 URL",
+ url: "http://127.0.1.1:5678/",
+ valid: false,
+ },
+ {
+ description: "Current instance API URL",
+ url: "https://try.gitea.io/api/v1/user/follow",
+ valid: false,
+ },
+ {
+ description: "Local network URL",
+ url: "http://192.168.1.2/api/v1/user/follow",
+ valid: true,
+ },
+ {
+ description: "Local URL",
+ url: "http://LOCALHOST:1234/whatever",
+ valid: false,
+ },
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.description, func(t *testing.T) {
+ assert.Equal(t, testCase.valid, IsValidExternalURL(testCase.url))
+ })
+ }
+}
+
+func Test_IsValidExternalTrackerURLFormat(t *testing.T) {
+ setting.AppURL = "https://try.gitea.io/"
+
+ cases := []struct {
+ description string
+ url string
+ valid bool
+ }{
+ {
+ description: "Correct external tracker URL with all placeholders",
+ url: "https://github.com/{user}/{repo}/issues/{index}",
+ valid: true,
+ },
+ {
+ description: "Local external tracker URL with all placeholders",
+ url: "https://127.0.0.1/{user}/{repo}/issues/{index}",
+ valid: false,
+ },
+ {
+ description: "External tracker URL with typo placeholder",
+ url: "https://github.com/{user}/{repo/issues/{index}",
+ valid: false,
+ },
+ {
+ description: "External tracker URL with typo placeholder",
+ url: "https://github.com/[user}/{repo/issues/{index}",
+ valid: false,
+ },
+ {
+ description: "External tracker URL with typo placeholder",
+ url: "https://github.com/{user}/repo}/issues/{index}",
+ valid: false,
+ },
+ {
+ description: "External tracker URL missing optional placeholder",
+ url: "https://github.com/{user}/issues/{index}",
+ valid: true,
+ },
+ {
+ description: "External tracker URL missing optional placeholder",
+ url: "https://github.com/{repo}/issues/{index}",
+ valid: true,
+ },
+ {
+ description: "External tracker URL missing optional placeholder",
+ url: "https://github.com/issues/{index}",
+ valid: true,
+ },
+ {
+ description: "External tracker URL missing optional placeholder",
+ url: "https://github.com/issues/{user}",
+ valid: true,
+ },
+ {
+ description: "External tracker URL with similar placeholder names test",
+ url: "https://github.com/user/repo/issues/{index}",
+ valid: true,
+ },
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.description, func(t *testing.T) {
+ assert.Equal(t, testCase.valid, IsValidExternalTrackerURLFormat(testCase.url))
+ })
+ }
+}
+
+func TestIsValidUsernameAllowDots(t *testing.T) {
+ setting.Service.AllowDotsInUsernames = true
+ tests := []struct {
+ arg string
+ want bool
+ }{
+ {arg: "a", want: true},
+ {arg: "abc", want: true},
+ {arg: "0.b-c", want: true},
+ {arg: "a.b-c_d", want: true},
+ {arg: "", want: false},
+ {arg: ".abc", want: false},
+ {arg: "abc.", want: false},
+ {arg: "a..bc", want: false},
+ {arg: "a...bc", want: false},
+ {arg: "a.-bc", want: false},
+ {arg: "a._bc", want: false},
+ {arg: "a_-bc", want: false},
+ {arg: "a/bc", want: false},
+ {arg: "☁️", want: false},
+ {arg: "-", want: false},
+ {arg: "--diff", want: false},
+ {arg: "-im-here", want: false},
+ {arg: "a space", want: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.arg, func(t *testing.T) {
+ assert.Equalf(t, tt.want, IsValidUsername(tt.arg), "IsValidUsername(%v)", tt.arg)
+ })
+ }
+}
+
+func TestIsValidUsernameBanDots(t *testing.T) {
+ setting.Service.AllowDotsInUsernames = false
+ defer func() {
+ setting.Service.AllowDotsInUsernames = true
+ }()
+
+ tests := []struct {
+ arg string
+ want bool
+ }{
+ {arg: "a", want: true},
+ {arg: "abc", want: true},
+ {arg: "0.b-c", want: false},
+ {arg: "a.b-c_d", want: false},
+ {arg: ".abc", want: false},
+ {arg: "abc.", want: false},
+ {arg: "a..bc", want: false},
+ {arg: "a...bc", want: false},
+ {arg: "a.-bc", want: false},
+ {arg: "a._bc", want: false},
+ }
+ for _, tt := range tests {
+ t.Run(tt.arg, func(t *testing.T) {
+ assert.Equalf(t, tt.want, IsValidUsername(tt.arg), "IsValidUsername[AllowDotsInUsernames=false](%v)", tt.arg)
+ })
+ }
+}
diff --git a/modules/validation/refname_test.go b/modules/validation/refname_test.go
new file mode 100644
index 0000000..3af7387
--- /dev/null
+++ b/modules/validation/refname_test.go
@@ -0,0 +1,265 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "gitea.com/go-chi/binding"
+)
+
+var gitRefNameValidationTestCases = []validationTestCase{
+ {
+ description: "Reference name contains only characters",
+ data: TestForm{
+ BranchName: "test",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Reference name contains single slash",
+ data: TestForm{
+ BranchName: "feature/test",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Reference name has allowed special characters",
+ data: TestForm{
+ BranchName: "debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Reference name contains backslash",
+ data: TestForm{
+ BranchName: "feature\\test",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name starts with dot",
+ data: TestForm{
+ BranchName: ".test",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name ends with dot",
+ data: TestForm{
+ BranchName: "test.",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name starts with slash",
+ data: TestForm{
+ BranchName: "/test",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name ends with slash",
+ data: TestForm{
+ BranchName: "test/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name ends with .lock",
+ data: TestForm{
+ BranchName: "test.lock",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name contains multiple consecutive dots",
+ data: TestForm{
+ BranchName: "te..st",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name contains multiple consecutive slashes",
+ data: TestForm{
+ BranchName: "te//st",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name is single @",
+ data: TestForm{
+ BranchName: "@",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has @{",
+ data: TestForm{
+ BranchName: "branch@{",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character ~",
+ data: TestForm{
+ BranchName: "~debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character *",
+ data: TestForm{
+ BranchName: "*debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character ?",
+ data: TestForm{
+ BranchName: "?debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character ^",
+ data: TestForm{
+ BranchName: "^debian/1%1.6.0-2",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character :",
+ data: TestForm{
+ BranchName: "debian:jessie",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character (whitespace)",
+ data: TestForm{
+ BranchName: "debian jessie",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+ {
+ description: "Reference name has unallowed special character [",
+ data: TestForm{
+ BranchName: "debian[jessie",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"BranchName"},
+ Classification: ErrGitRefName,
+ Message: "GitRefName",
+ },
+ },
+ },
+}
+
+func Test_GitRefNameValidation(t *testing.T) {
+ AddBindingRules()
+
+ for _, testCase := range gitRefNameValidationTestCases {
+ t.Run(testCase.description, func(t *testing.T) {
+ performValidationTest(t, testCase)
+ })
+ }
+}
diff --git a/modules/validation/regex_pattern_test.go b/modules/validation/regex_pattern_test.go
new file mode 100644
index 0000000..efcb276
--- /dev/null
+++ b/modules/validation/regex_pattern_test.go
@@ -0,0 +1,59 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "regexp"
+ "testing"
+
+ "gitea.com/go-chi/binding"
+)
+
+func getRegexPatternErrorString(pattern string) string {
+ if _, err := regexp.Compile(pattern); err != nil {
+ return err.Error()
+ }
+ return ""
+}
+
+var regexValidationTestCases = []validationTestCase{
+ {
+ description: "Empty regex pattern",
+ data: TestForm{
+ RegexPattern: "",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Valid regex",
+ data: TestForm{
+ RegexPattern: `(\d{1,3})+`,
+ },
+ expectedErrors: binding.Errors{},
+ },
+
+ {
+ description: "Invalid regex",
+ data: TestForm{
+ RegexPattern: "[a-",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"RegexPattern"},
+ Classification: ErrRegexPattern,
+ Message: getRegexPatternErrorString("[a-"),
+ },
+ },
+ },
+}
+
+func Test_RegexPatternValidation(t *testing.T) {
+ AddBindingRules()
+
+ for _, testCase := range regexValidationTestCases {
+ t.Run(testCase.description, func(t *testing.T) {
+ performValidationTest(t, testCase)
+ })
+ }
+}
diff --git a/modules/validation/validatable.go b/modules/validation/validatable.go
new file mode 100644
index 0000000..94b5cc1
--- /dev/null
+++ b/modules/validation/validatable.go
@@ -0,0 +1,84 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// Copyright 2023 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "fmt"
+ "reflect"
+ "strings"
+ "unicode/utf8"
+
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+// ErrNotValid represents an validation error
+type ErrNotValid struct {
+ Message string
+}
+
+func (err ErrNotValid) Error() string {
+ return fmt.Sprintf("Validation Error: %v", err.Message)
+}
+
+// IsErrNotValid checks if an error is a ErrNotValid.
+func IsErrNotValid(err error) bool {
+ _, ok := err.(ErrNotValid)
+ return ok
+}
+
+type Validateable interface {
+ Validate() []string
+}
+
+func IsValid(v Validateable) (bool, error) {
+ if err := v.Validate(); len(err) > 0 {
+ typeof := reflect.TypeOf(v)
+ errString := strings.Join(err, "\n")
+ return false, ErrNotValid{fmt.Sprint(typeof, ": ", errString)}
+ }
+
+ return true, nil
+}
+
+func ValidateNotEmpty(value any, name string) []string {
+ isValid := true
+ switch v := value.(type) {
+ case string:
+ if v == "" {
+ isValid = false
+ }
+ case timeutil.TimeStamp:
+ if v.IsZero() {
+ isValid = false
+ }
+ case int64:
+ if v == 0 {
+ isValid = false
+ }
+ default:
+ isValid = false
+ }
+
+ if isValid {
+ return []string{}
+ }
+ return []string{fmt.Sprintf("%v should not be empty", name)}
+}
+
+func ValidateMaxLen(value string, maxLen int, name string) []string {
+ if utf8.RuneCountInString(value) > maxLen {
+ return []string{fmt.Sprintf("Value %v was longer than %v", name, maxLen)}
+ }
+ return []string{}
+}
+
+func ValidateOneOf(value any, allowed []any, name string) []string {
+ for _, allowedElem := range allowed {
+ if value == allowedElem {
+ return []string{}
+ }
+ }
+ return []string{fmt.Sprintf("Value %v is not contained in allowed values %v", value, allowed)}
+}
diff --git a/modules/validation/validatable_test.go b/modules/validation/validatable_test.go
new file mode 100644
index 0000000..919f5a3
--- /dev/null
+++ b/modules/validation/validatable_test.go
@@ -0,0 +1,69 @@
+// Copyright 2024 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "code.gitea.io/gitea/modules/timeutil"
+)
+
+type Sut struct {
+ valid bool
+}
+
+func (sut Sut) Validate() []string {
+ if sut.valid {
+ return []string{}
+ }
+ return []string{"invalid"}
+}
+
+func Test_IsValid(t *testing.T) {
+ sut := Sut{valid: true}
+ if res, _ := IsValid(sut); !res {
+ t.Errorf("sut expected to be valid: %v\n", sut.Validate())
+ }
+ sut = Sut{valid: false}
+ res, err := IsValid(sut)
+ if res {
+ t.Errorf("sut expected to be invalid: %v\n", sut.Validate())
+ }
+ if err == nil || !IsErrNotValid(err) || err.Error() != "Validation Error: validation.Sut: invalid" {
+ t.Errorf("validation error expected, but was %v", err)
+ }
+}
+
+func Test_ValidateNotEmpty_ForString(t *testing.T) {
+ sut := ""
+ if len(ValidateNotEmpty(sut, "dummyField")) == 0 {
+ t.Errorf("sut should be invalid")
+ }
+ sut = "not empty"
+ if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 {
+ t.Errorf("sut should be valid but was %q", res)
+ }
+}
+
+func Test_ValidateNotEmpty_ForTimestamp(t *testing.T) {
+ sut := timeutil.TimeStamp(0)
+ if res := ValidateNotEmpty(sut, "dummyField"); len(res) == 0 {
+ t.Errorf("sut should be invalid")
+ }
+ sut = timeutil.TimeStampNow()
+ if res := ValidateNotEmpty(sut, "dummyField"); len(res) > 0 {
+ t.Errorf("sut should be valid but was %q", res)
+ }
+}
+
+func Test_ValidateMaxLen(t *testing.T) {
+ sut := "0123456789"
+ if len(ValidateMaxLen(sut, 9, "dummyField")) == 0 {
+ t.Errorf("sut should be invalid")
+ }
+ sut = "0123456789"
+ if res := ValidateMaxLen(sut, 11, "dummyField"); len(res) > 0 {
+ t.Errorf("sut should be valid but was %q", res)
+ }
+}
diff --git a/modules/validation/validurl_test.go b/modules/validation/validurl_test.go
new file mode 100644
index 0000000..39f7fa5
--- /dev/null
+++ b/modules/validation/validurl_test.go
@@ -0,0 +1,110 @@
+// Copyright 2017 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package validation
+
+import (
+ "testing"
+
+ "gitea.com/go-chi/binding"
+)
+
+var urlValidationTestCases = []validationTestCase{
+ {
+ description: "Empty URL",
+ data: TestForm{
+ URL: "",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "URL without port",
+ data: TestForm{
+ URL: "http://test.lan/",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "URL with port",
+ data: TestForm{
+ URL: "http://test.lan:3000/",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "URL with IPv6 address without port",
+ data: TestForm{
+ URL: "http://[::1]/",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "URL with IPv6 address with port",
+ data: TestForm{
+ URL: "http://[::1]:3000/",
+ },
+ expectedErrors: binding.Errors{},
+ },
+ {
+ description: "Invalid URL",
+ data: TestForm{
+ URL: "http//test.lan/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"URL"},
+ Classification: binding.ERR_URL,
+ Message: "Url",
+ },
+ },
+ },
+ {
+ description: "Invalid schema",
+ data: TestForm{
+ URL: "ftp://test.lan/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"URL"},
+ Classification: binding.ERR_URL,
+ Message: "Url",
+ },
+ },
+ },
+ {
+ description: "Invalid port",
+ data: TestForm{
+ URL: "http://test.lan:3x4/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"URL"},
+ Classification: binding.ERR_URL,
+ Message: "Url",
+ },
+ },
+ },
+ {
+ description: "Invalid port with IPv6 address",
+ data: TestForm{
+ URL: "http://[::1]:3x4/",
+ },
+ expectedErrors: binding.Errors{
+ binding.Error{
+ FieldNames: []string{"URL"},
+ Classification: binding.ERR_URL,
+ Message: "Url",
+ },
+ },
+ },
+}
+
+func Test_ValidURLValidation(t *testing.T) {
+ AddBindingRules()
+
+ for _, testCase := range urlValidationTestCases {
+ t.Run(testCase.description, func(t *testing.T) {
+ performValidationTest(t, testCase)
+ })
+ }
+}