diff --git a/pkg/rules/error_codes.go b/pkg/rules/error_codes.go index 16a4f46..c7b9b55 100644 --- a/pkg/rules/error_codes.go +++ b/pkg/rules/error_codes.go @@ -38,6 +38,7 @@ const ( ErrorCodeStringMinLength govy.ErrorCode = "string_min_length" ErrorCodeStringMaxLength govy.ErrorCode = "string_max_length" ErrorCodeStringTitle govy.ErrorCode = "string_title" + ErrorCodeStringGitRef govy.ErrorCode = "string_git_ref" ErrorCodeSliceLength govy.ErrorCode = "slice_length" ErrorCodeSliceMinLength govy.ErrorCode = "slice_min_length" ErrorCodeSliceMaxLength govy.ErrorCode = "slice_max_length" diff --git a/pkg/rules/string.go b/pkg/rules/string.go index a3e33b5..b62411b 100644 --- a/pkg/rules/string.go +++ b/pkg/rules/string.go @@ -327,6 +327,104 @@ func StringTitle() govy.Rule[string] { WithDescription(msg) } +// StringGitRef validates a reference name. +// This follows the git-check-ref-format rules. +// See https://git-scm.com/docs/git-check-ref-format +// +// It is important to note that this function does not check if the reference exists in the repository. +// It only checks if the reference name is valid. +// This functions does not support the '--refspec-pattern', '--normalize', and '--allow-onelevel' options. +// +// Git imposes the following rules on how references are named: +// +// 1. They can include slash '/' for hierarchical (directory) grouping, but no +// slash-separated component can begin with a dot '.' or end with the +// sequence '.lock'. +// 2. They must contain at least one '/'. This enforces the presence of a +// category (e.g. 'heads/', 'tags/'), but the actual names are not restricted. +// 3. They cannot have ASCII control characters (i.e. bytes whose values are +// lower than '\040', or '\177' DEL). +// 4. They cannot have '?', '*', '[', ' ', '~', '^', ', '\t', '\n', '@{', '\\' and '..', +// 5. They cannot begin or end with a slash '/'. +// 6. They cannot end with a '.'. +// 7. They cannot be the single character '@'. +// 8. 'HEAD' is an allowed special name. +// +// Slightly modified version of [go-git] implementation, kudos the authors! +// +// [go-git]: https://github.com/go-git/go-git/blob/95afe7e1cdf71c59ee8a71971fac71880020a744/plumbing/reference.go#L167 +func StringGitRef() govy.Rule[string] { + msg := "string must be a valid git reference" + return govy.NewRule(func(s string) error { + if len(s) == 0 { + return errGitRefEmpty + } + if s == "HEAD" { + return nil + } + if strings.HasSuffix(s, ".") { + return errGitRefEndsWithDot + } + parts := strings.Split(s, "/") + if len(parts) < 2 { + return errGitRefAtLeastOneSlash + } + isBranch := strings.HasPrefix(s, "refs/heads/") + isTag := strings.HasPrefix(s, "refs/tags/") + for _, part := range parts { + if len(part) == 0 { + return errGitRefEmptyPart + } + if (isBranch || isTag) && strings.HasPrefix(part, "-") { + return errGitRefStartsWithDash + } + if part == "@" || + strings.HasPrefix(part, ".") || + strings.HasSuffix(part, ".lock") || + stringContainsGitRefForbiddenChars(part) { + return errGitRefForbiddenChars + } + } + return nil + }). + WithErrorCode(ErrorCodeStringGitRef). + WithDetails("see https://git-scm.com/docs/git-check-ref-format for more information on Git reference naming rules"). + WithDescription(msg) +} + +var ( + errGitRefEmpty = errors.New("git reference cannot be empty") + errGitRefEndsWithDot = errors.New("git reference must not end with a '.'") + errGitRefAtLeastOneSlash = errors.New("git reference must contain at least one '/'") + errGitRefEmptyPart = errors.New("git reference must not have empty parts") + errGitRefStartsWithDash = errors.New("git branch and tag references must not start with '-'") + errGitRefForbiddenChars = errors.New("git reference contains forbidden characters") +) + +var gitRefDisallowedStrings = map[rune]struct{}{ + '\\': {}, '?': {}, '*': {}, '[': {}, ' ': {}, '~': {}, '^': {}, ':': {}, '\t': {}, '\n': {}, +} + +// stringContainsGitRefForbiddenChars is a brute force method to check if a string contains +// any of the Git reference forbidden characters. +func stringContainsGitRefForbiddenChars(s string) bool { + for i, c := range s { + if c == '\177' || (c >= '\000' && c <= '\037') { + return true + } + // Check for '..' and '@{'. + if c == '.' && i < len(s)-1 && s[i+1] == '.' || + c == '@' && i < len(s)-1 && s[i+1] == '{' { + return true + } + if _, ok := gitRefDisallowedStrings[c]; !ok { + continue + } + return true + } + return false +} + func prettyExamples(examples []string) string { if len(examples) == 0 { return "" diff --git a/pkg/rules/string_test.go b/pkg/rules/string_test.go index 7e5ad92..e1b44d7 100644 --- a/pkg/rules/string_test.go +++ b/pkg/rules/string_test.go @@ -879,3 +879,80 @@ func BenchmarkStringTitle(b *testing.B) { } } } + +var stringGitRefTestCases = []*struct { + in string + expectedErr error +}{ + {"refs/heads/master", nil}, + {"refs/notes/commits", nil}, + {"refs/tags/this@", nil}, + {"refs/remotes/origin/master", nil}, + {"HEAD", nil}, + {"refs/tags/v3.1.1", nil}, + {"refs/pulls/1/head", nil}, + {"refs/pulls/1/merge", nil}, + {"refs/pulls/1/abc.123", nil}, + {"refs/pulls", nil}, + {"refs/-", nil}, + {"refs", errGitRefAtLeastOneSlash}, + {"refs/", errGitRefEmptyPart}, + {"refs//", errGitRefEmptyPart}, + {"refs/heads/\\", errGitRefForbiddenChars}, + {"refs/heads/\\foo", errGitRefForbiddenChars}, + {"refs/heads/\\foo/bar", errGitRefForbiddenChars}, + {"abc", errGitRefAtLeastOneSlash}, + {"", errGitRefEmpty}, + {"refs/heads/ ", errGitRefForbiddenChars}, + {"refs/heads/ /", errGitRefForbiddenChars}, + {"refs/heads/ /foo", errGitRefForbiddenChars}, + {"refs/heads/.", errGitRefEndsWithDot}, + {"refs/heads/..", errGitRefEndsWithDot}, + {"refs/heads/foo..", errGitRefEndsWithDot}, + {"refs/heads/foo.lock", errGitRefForbiddenChars}, + {"refs/heads/foo@{bar}", errGitRefForbiddenChars}, + {"refs/heads/foo@{", errGitRefForbiddenChars}, + {"refs/heads/foo[", errGitRefForbiddenChars}, + {"refs/heads/foo~", errGitRefForbiddenChars}, + {"refs/heads/foo^", errGitRefForbiddenChars}, + {"refs/heads/foo:", errGitRefForbiddenChars}, + {"refs/heads/foo?", errGitRefForbiddenChars}, + {"refs/heads/foo*", errGitRefForbiddenChars}, + {"refs/heads/foo[bar", errGitRefForbiddenChars}, + {"refs/heads/foo\t", errGitRefForbiddenChars}, + {"refs/heads/@", errGitRefForbiddenChars}, + {"refs/heads/@{bar}", errGitRefForbiddenChars}, + {"refs/heads/\n", errGitRefForbiddenChars}, + {"refs/heads/-foo", errGitRefStartsWithDash}, + {"refs/heads/foo..bar", errGitRefForbiddenChars}, + {"refs/heads/-", errGitRefStartsWithDash}, + {"refs/tags/-", errGitRefStartsWithDash}, + {"refs/tags/-foo", errGitRefStartsWithDash}, +} + +func TestStringGitRef(t *testing.T) { + for _, tc := range stringGitRefTestCases { + t.Run(tc.in, func(t *testing.T) { + err := StringGitRef().Validate(tc.in) + if tc.expectedErr != nil { + assert.ErrorContains(t, err, tc.expectedErr.Error()) + assert.True(t, govy.HasErrorCode(err, ErrorCodeStringGitRef)) + assert.Equal( + t, + "see https://git-scm.com/docs/git-check-ref-format for more information on Git reference naming rules", + err.(*govy.RuleError).Details, + ) + } else { + assert.NoError(t, err) + } + }) + } +} + +func BenchmarkStringGitRef(b *testing.B) { + for range b.N { + for _, tc := range stringGitRefTestCases { + _ = StringGitRef().Validate(tc.in) + } + } +}