Skip to content

Commit

Permalink
feat: Add StringGitRef rule (#35)
Browse files Browse the repository at this point in the history
## Summary

Added `StringGitRef` validation rule which checks if a string is a valid
git reference according to the rules defined here:
https://git-scm.com/docs/git-check-ref-format.

## Release Notes

Added `StringGitRef` validation rule which checks if a string is a valid
git reference according to the rules defined by
[git-check-ref-format](https://git-scm.com/docs/git-check-ref-format).
  • Loading branch information
nieomylnieja authored Oct 17, 2024
1 parent edc174e commit 71d0650
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 0 deletions.
1 change: 1 addition & 0 deletions pkg/rules/error_codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
98 changes: 98 additions & 0 deletions pkg/rules/string.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
77 changes: 77 additions & 0 deletions pkg/rules/string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

0 comments on commit 71d0650

Please sign in to comment.