diff --git a/Makefile b/Makefile index 9ca87a2..505e31c 100644 --- a/Makefile +++ b/Makefile @@ -20,9 +20,14 @@ test-cover: fuzz: @echo "==> Running Fuzz Tests" go env GOCACHE - go test -fuzz=FuzzNewVersion -fuzztime=15s . - go test -fuzz=FuzzStrictNewVersion -fuzztime=15s . - go test -fuzz=FuzzNewConstraint -fuzztime=15s . + # The -fuzz pattern is an unanchored regular expression and only one fuzz + # target may be run at a time, so each pattern is anchored. + go test -fuzz='^FuzzNewVersion$$' -fuzztime=15s . + go test -fuzz='^FuzzStrictNewVersion$$' -fuzztime=15s . + go test -fuzz='^FuzzNewConstraint$$' -fuzztime=15s . + go test -fuzz='^FuzzNewVersionDifferential$$' -fuzztime=15s . + go test -fuzz='^FuzzStrictNewVersionDifferential$$' -fuzztime=15s . + go test -fuzz='^FuzzNewConstraintDifferential$$' -fuzztime=15s . $(GOLANGCI_LINT): # Install golangci-lint. The configuration for it is in the .golangci.yml diff --git a/README.md b/README.md index 2f56c67..92595c7 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,13 @@ for i, r := range raw { sort.Sort(semver.Collection(vs)) ``` +The `Sort` function does the same thing without going through the `sort` +package, which saves an interface dispatch on each comparison. + +```go +semver.Sort(vs) +``` + ## Checking Version Constraints There are two methods for comparing versions. One uses comparison methods on diff --git a/benchmark_test.go b/benchmark_test.go index 2804563..a24dc3a 100644 --- a/benchmark_test.go +++ b/benchmark_test.go @@ -1,6 +1,7 @@ package semver import ( + "sort" "testing" ) @@ -245,3 +246,68 @@ func BenchmarkStrictNewVersionMetaDash(b *testing.B) { b.ResetTimer() benchStrictNewVersion("1.0.0-alpha.1+meta.data", b) } + +/* Comparison benchmarks */ + +func benchCompare(v1, v2 string, b *testing.B) { + a, _ := NewVersion(v1) + c, _ := NewVersion(v2) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + a.Compare(c) + } +} + +func BenchmarkCompareSimple(b *testing.B) { + benchCompare("1.2.3", "1.2.4", b) +} + +func BenchmarkComparePrerelease(b *testing.B) { + benchCompare("1.2.3-alpha.1", "1.2.3-alpha.2", b) +} + +func BenchmarkComparePrereleaseLong(b *testing.B) { + benchCompare("1.2.3-alpha.beta.11.rc.1", "1.2.3-alpha.beta.11.rc.2", b) +} + +/* Sorting benchmarks */ + +// sortCorpus is a set of versions covering release and prerelease values +// across several major, minor, and patch segments. +var sortCorpus = []string{ + "1.2.3", "0.4.2", "2.0.0", "1.0.0-alpha", "1.0.0-alpha.1", + "1.0.0-alpha.beta", "1.0.0-beta", "1.0.0-beta.2", "1.0.0-beta.11", + "1.0.0-rc.1", "1.0.0", "1.3.0", "1.2.0", "3.1.4", "2.7.18", + "0.0.1", "10.0.0", "1.2.3+meta", "4.5.6-rc.1+build.1", "2.0.0-rc.2", +} + +func BenchmarkCollectionSort(b *testing.B) { + vs := make(Collection, len(sortCorpus)) + for i, r := range sortCorpus { + vs[i] = MustParse(r) + } + + work := make(Collection, len(vs)) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + copy(work, vs) + sort.Sort(work) + } +} + +func BenchmarkCollectionSortFunc(b *testing.B) { + vs := make(Collection, len(sortCorpus)) + for i, r := range sortCorpus { + vs[i] = MustParse(r) + } + + work := make(Collection, len(vs)) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + copy(work, vs) + Sort(work) + } +} diff --git a/collection.go b/collection.go index a782358..714160c 100644 --- a/collection.go +++ b/collection.go @@ -1,5 +1,7 @@ package semver +import "slices" + // Collection is a collection of Version instances and implements the sort // interface. See the sort package for more details. // https://golang.org/pkg/sort/ @@ -22,3 +24,12 @@ func (c Collection) Less(i, j int) bool { func (c Collection) Swap(i, j int) { c[i], c[j] = c[j], c[i] } + +// Sort sorts a Collection of versions from lowest to highest. It is equivalent +// to sort.Sort(c) but does not go through the sort.Interface methods, so each +// comparison avoids an interface dispatch. +func Sort(c Collection) { + slices.SortFunc(c, func(a, b *Version) int { + return a.Compare(b) + }) +} diff --git a/collection_test.go b/collection_test.go index 71b909c..d7984dc 100644 --- a/collection_test.go +++ b/collection_test.go @@ -44,3 +44,56 @@ func TestCollection(t *testing.T) { t.Error("Sorting Collection failed") } } + +func TestSort(t *testing.T) { + raw := []string{ + "1.2.3", + "1.0", + "1.3", + "2", + "0.4.2", + "1.0.0-alpha", + "1.0.0-beta.2", + "1.0.0-beta.11", + } + + vs := make(Collection, len(raw)) + for i, r := range raw { + v, err := NewVersion(r) + if err != nil { + t.Errorf("Error parsing version: %s", err) + } + + vs[i] = v + } + + // Sorting through the sort interface and through the helper must agree. + expected := make(Collection, len(vs)) + copy(expected, vs) + sort.Sort(expected) + + Sort(vs) + + e := []string{ + "0.4.2", + "1.0.0-alpha", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0", + "1.2.3", + "1.3.0", + "2.0.0", + } + + a := make([]string, len(vs)) + for i, v := range vs { + a[i] = v.String() + if !v.Equal(expected[i]) { + t.Errorf("Sort and sort.Sort disagree at %d: %s and %s", i, v, expected[i]) + } + } + + if !reflect.DeepEqual(a, e) { + t.Errorf("Sorting Collection failed, got %v", a) + } +} diff --git a/constraints.go b/constraints.go index e8353bc..caa9ff8 100644 --- a/constraints.go +++ b/constraints.go @@ -1,10 +1,10 @@ package semver import ( - "bytes" "errors" "fmt" "regexp" + "strconv" "strings" ) @@ -55,31 +55,21 @@ func NewConstraint(c string) (*Constraints, error) { or := make([][]*constraint, lenors) hasPre := make([]bool, lenors) for k, v := range ors { - // Validate the segment - if !validConstraintRegex.MatchString(v) { - return nil, fmt.Errorf("improper constraint: %q", v) - } - - cs := findConstraintRegex.FindAllString(v, -1) - if cs == nil { - cs = append(cs, v) + result, err := parseConstraintGroup(v) + if err != nil { + return nil, err } - result := make([]*constraint, len(cs)) - for i, s := range cs { - pc, err := parseConstraint(s) - if err != nil { - return nil, err - } + for _, pc := range result { // If one of the constraints has a prerelease record this. // This information is used when checking all in an "and" // group to ensure they all check for prereleases. if pc.con.pre != "" { hasPre[k] = true + break } - - result[i] = pc } + or[k] = result } @@ -98,7 +88,7 @@ func (cs Constraints) Check(v *Version) bool { for i, o := range cs.constraints { joy := true for _, c := range o { - if check, _ := c.check(v, (cs.IncludePrerelease || cs.containsPre[i])); !check { + if !c.ok(v, cs.IncludePrerelease || cs.containsPre[i]) { joy = false break } @@ -152,24 +142,26 @@ func (cs Constraints) Validate(v *Version) (bool, []error) { } func (cs Constraints) String() string { - buf := make([]string, len(cs.constraints)) - var tmp bytes.Buffer + var buf strings.Builder for k, v := range cs.constraints { - tmp.Reset() - vlen := len(v) - for kk, c := range v { - tmp.WriteString(c.string()) + // Separate the OR groups + if k > 0 { + buf.WriteString(" || ") + } + for kk, c := range v { // Space separate the AND conditions - if vlen > 1 && kk < vlen-1 { - tmp.WriteString(" ") + if kk > 0 { + buf.WriteString(" ") } + + buf.WriteString(c.origfunc) + buf.WriteString(c.orig) } - buf[k] = tmp.String() } - return strings.Join(buf, " || ") + return buf.String() } // UnmarshalText implements the encoding.TextUnmarshaler interface. @@ -190,14 +182,16 @@ func (cs Constraints) MarshalText() ([]byte, error) { } var constraintOps map[string]cfunc -var constraintRegex *regexp.Regexp -var constraintRangeRegex *regexp.Regexp -// Used to find individual constraints within a multi-constraint string -var findConstraintRegex *regexp.Regexp +// Used to rewrite a hyphenated range into a pair of comparisons +var constraintRangeRegex *regexp.Regexp -// Used to validate an segment of ANDs is valid -var validConstraintRegex *regexp.Regexp +// constraintSegChars is a lookup table for the characters allowed in the +// numeric segments of a constraint version. Along with the digits it holds the +// wildcards and, for compatibility with the regular expression that this +// scanner replaced, a literal |. Segments that are not a lone wildcard are +// checked for being numeric when the version is built. +var constraintSegChars [256]bool const cvRegex string = `v?([0-9|x|X|\*]+)(\.[0-9|x|X|\*]+)?(\.[0-9|x|X|\*]+)?` + `(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + @@ -219,31 +213,13 @@ func init() { "^": constraintCaret, } - ops := `=||!=|>|<|>=|=>|<=|=<|~|~>|\^` - - constraintRegex = regexp.MustCompile(fmt.Sprintf( - `^\s*(%s)\s*(%s)\s*$`, - ops, - cvRegex)) + for _, ch := range []byte("0123456789xX*|") { + constraintSegChars[ch] = true + } constraintRangeRegex = regexp.MustCompile(fmt.Sprintf( `\s*(%s)\s+-\s+(%s)\s*`, cvRegex, cvRegex)) - - findConstraintRegex = regexp.MustCompile(fmt.Sprintf( - `(%s)\s*(%s)`, - ops, - cvRegex)) - - // The first time a constraint shows up will look slightly different from - // future times it shows up due to a leading space or comma in a given - // string. - validConstraintRegex = regexp.MustCompile(fmt.Sprintf( - `^(\s*(%s)\s*(%s)\s*)((?:\s+|,\s*)(%s)\s*(%s)\s*)*$`, - ops, - cvRegex, - ops, - cvRegex)) } // An individual constraint @@ -258,135 +234,434 @@ type constraint struct { // The original operator for the constraint origfunc string + // The function that performs the check, resolved from origfunc when the + // constraint is parsed so that checking does not need a map lookup. + cf cfunc + // When an x is used as part of the version (e.g., 1.x) minorDirty bool dirty bool patchDirty bool } -// Check if a version meets the constraint +// Check if a version meets the constraint. The error explains why the check +// failed. Callers that ignore the error should use ok instead so that nothing +// is formatted. func (c *constraint) check(v *Version, includePre bool) (bool, error) { - return constraintOps[c.origfunc](v, c, includePre) + res, r := c.cf(v, c, includePre) + if res { + return true, nil + } + return false, r.err(v, c) } -// String prints an individual constraint into a string -func (c *constraint) string() string { - return c.origfunc + c.orig +// ok reports if a version meets the constraint. Unlike check it does not build +// the message explaining a failure, which is the bulk of the cost of a failing +// check. +func (c *constraint) ok(v *Version, includePre bool) bool { + res, _ := c.cf(v, c, includePre) + return res } -type cfunc func(v *Version, c *constraint, includePre bool) (bool, error) +type cfunc func(v *Version, c *constraint, includePre bool) (bool, failReason) + +// failReason identifies why a constraint check failed. Constraint functions +// return a reason rather than an error so that the message is only built when +// a caller asks for it. Check discards the reason, Validate turns it into an +// error. +type failReason uint8 + +const ( + reasonNone failReason = iota + reasonPrerelease + reasonEqual + reasonNotEqual + reasonLessThan + reasonLessThanEqual + reasonGreaterThan + reasonGreaterThanEqual + reasonMajor + reasonMajorMinor + reasonCaretMinor + reasonCaretMinorZero + reasonCaretPatch +) -func parseConstraint(c string) (*constraint, error) { - if len(c) > 0 { - m := constraintRegex.FindStringSubmatch(c) - if m == nil { - return nil, fmt.Errorf("improper constraint: %q", c) - } - - cs := &constraint{ - orig: m[2], - origfunc: m[1], - } - - ver := m[2] - minorDirty := false - patchDirty := false - dirty := false - if isX(m[3]) || m[3] == "" { - ver = fmt.Sprintf("0.0.0%s", m[6]) - dirty = true - } else if isX(strings.TrimPrefix(m[4], ".")) || m[4] == "" { - minorDirty = true - dirty = true - ver = fmt.Sprintf("%s.0.0%s", m[3], m[6]) - } else if isX(strings.TrimPrefix(m[5], ".")) || m[5] == "" { - dirty = true - patchDirty = true - ver = fmt.Sprintf("%s%s.0%s", m[3], m[4], m[6]) - } - - con, err := NewVersion(ver) +// reasonFormats holds the message for each failReason. Every message takes the +// version and the original constraint text, except reasonPrerelease which +// takes the version alone. +var reasonFormats = [...]string{ + reasonNone: "%q does not satisfy %q", + reasonPrerelease: "%q is a prerelease version and the constraint is only looking for release versions", + reasonEqual: "%q is equal to %q", + reasonNotEqual: "%q is not equal to %q", + reasonLessThan: "%q is less than %q", + reasonLessThanEqual: "%q is less than or equal to %q", + reasonGreaterThan: "%q is greater than %q", + reasonGreaterThanEqual: "%q is greater than or equal to %q", + reasonMajor: "%q does not have same major version as %q", + reasonMajorMinor: "%q does not have same major and minor version as %q", + reasonCaretMinor: "%q does not have same minor version as %q. Expected minor versions to match when constraint major version is 0", + reasonCaretMinorZero: "%q does not have same minor version as %q", + reasonCaretPatch: "%q does not equal %q. Expect version and constraint to equal when major and minor versions are 0", +} + +// err builds the error describing a failed check. +func (r failReason) err(v *Version, c *constraint) error { + if r == reasonPrerelease { + return fmt.Errorf(reasonFormats[reasonPrerelease], v) + } + return fmt.Errorf(reasonFormats[r], v, c.orig) +} + +// errConstraintParser is returned when a constraint has the shape of one but +// holds a version that cannot be built. +var errConstraintParser = errors.New("constraint parser error") + +// isConstraintSpace reports if b is one of the characters that separate the +// parts of a constraint string. This is the set \s matched in the regular +// expressions the constraint scanner replaced. +func isConstraintSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\f' || b == '\r' +} + +// skipConstraintSpace returns s without its leading whitespace. +func skipConstraintSpace(s string) string { + i := 0 + for i < len(s) && isConstraintSpace(s[i]) { + i++ + } + return s[i:] +} + +// parseConstraintGroup scans an AND group of constraints, such as +// ">=2.1.x, <3.1.0". Constraints within a group are separated by whitespace, a +// comma, or a comma surrounded by whitespace. +func parseConstraintGroup(g string) ([]*constraint, error) { + s := skipConstraintSpace(g) + if s == "" { + return nil, fmt.Errorf("improper constraint: %q", g) + } + + // Most groups hold one or two constraints. + result := make([]*constraint, 0, 2) + for { + c, rest, err := scanConstraint(s) if err != nil { + return nil, err + } + result = append(result, c) + + trimmed := skipConstraintSpace(rest) + if trimmed == "" { + return result, nil + } + + // Constraints must be separated. A comma may stand on its own, while + // whitespace is recognised by rest having been shortened. + switch { + case trimmed[0] == ',': + trimmed = skipConstraintSpace(trimmed[1:]) + case len(trimmed) == len(rest): + return nil, fmt.Errorf("improper constraint: %q", g) + } + + if trimmed == "" { + return nil, fmt.Errorf("improper constraint: %q", g) + } + s = trimmed + } +} + +// scanConstraint reads a single constraint from the front of s, which must not +// have leading whitespace, and returns it along with the unread remainder. +func scanConstraint(s string) (*constraint, string, error) { + op, rest := scanConstraintOp(s) + rest = skipConstraintSpace(rest) + + segs, n, pre, metadata, after, ok := scanConstraintVersion(rest) + if !ok { + return nil, "", fmt.Errorf("improper constraint: %q", s) + } + + c, err := newConstraint(op, rest[:len(rest)-len(after)], segs, n, pre, metadata) + if err != nil { + return nil, "", err + } + return c, after, nil +} + +// scanConstraintOp reads the operator from the front of s. An absent operator +// is an empty string, which is the same as =. +func scanConstraintOp(s string) (op, rest string) { + if len(s) >= 2 { + switch s[:2] { + case "!=", ">=", "=>", "<=", "=<", "~>": + return s[:2], s[2:] + } + } + if len(s) >= 1 { + switch s[0] { + case '=', '>', '<', '~', '^': + return s[:1], s[1:] + } + } + return "", s +} + +// scanConstraintVersion reads the version portion of a constraint from the +// front of s: an optional v, one to three segments of digits or a wildcard, +// then an optional prerelease and metadata. Anything that is not part of the +// version, such as a trailing dot, is left in rest for the caller to reject. +func scanConstraintVersion(s string) (segs [3]string, n int, pre, metadata, rest string, ok bool) { + i := 0 + if i < len(s) && s[i] == 'v' { + i++ + } + + for { + start := i + for i < len(s) && constraintSegChars[s[i]] { + i++ + } + if i == start { + return segs, 0, "", "", "", false + } + segs[n] = s[start:i] + n++ + + // Only the first three segments belong to the version, and a dot is + // only a separator when a segment follows it. + if n == 3 || i+1 >= len(s) || s[i] != '.' || !constraintSegChars[s[i+1]] { + break + } + i++ + } + + if i < len(s) && s[i] == '-' { + if end := scanIdentifiers(s, i+1); end > i+1 { + pre = s[i+1 : end] + i = end + } + } + + if i < len(s) && s[i] == '+' { + if end := scanIdentifiers(s, i+1); end > i+1 { + metadata = s[i+1 : end] + i = end + } + } + + return segs, n, pre, metadata, s[i:], true +} + +// scanIdentifiers returns the index just past the dot separated identifiers +// starting at index i in s. Identifiers hold the characters [0-9A-Za-z-] and +// must not be empty. When there is no identifier at i the returned index is i. +func scanIdentifiers(s string, i int) int { + end := i + for { + start := end + for end < len(s) && allowedChars[s[end]] { + end++ + } + if end == start { + return start + } + if end+1 < len(s) && s[end] == '.' && allowedChars[s[end+1]] { + end++ + continue + } + return end + } +} + +// newConstraint builds a constraint from the scanned pieces of one. A wildcard +// segment makes the constraint dirty and drops the segments that follow it, +// which is how a constraint such as 1.x comes to hold the version 1.0.0. +func newConstraint(op, orig string, segs [3]string, n int, pre, metadata string) (*constraint, error) { + cf, found := constraintOps[op] + if !found { + // scanConstraintOp only returns the operators in constraintOps, so we + // should never get here. + return nil, fmt.Errorf("improper constraint: %q", orig) + } + + c := &constraint{ + orig: orig, + origfunc: op, + cf: cf, + } + + // The version a constraint holds is never handed back to a caller, so the + // text the constraint was scanned from stands in as the original. + con := &Version{ + pre: pre, + original: orig, + } + + // The length of the version that would be built, so that the limit + // NewVersion applies is applied here too. + verLen := len("0.0.0") + if pre != "" { + verLen += 1 + len(pre) + } + + var err error + switch { + case isX(segs[0]): + // A wildcard major version matches everything. + c.dirty = true + case n < 2 || isX(segs[1]): + c.dirty = true + c.minorDirty = true + verLen += len(segs[0]) - 1 + if con.major, err = constraintSegment(segs[0]); err != nil { + return nil, err + } + case n < 3 || isX(segs[2]): + c.dirty = true + c.patchDirty = true + verLen += len(segs[0]) + len(segs[1]) - 2 + if con.major, err = constraintSegment(segs[0]); err != nil { + return nil, err + } + if con.minor, err = constraintSegment(segs[1]); err != nil { + return nil, err + } + default: + // Metadata is only kept when the version is fully specified. + con.metadata = metadata + verLen = len(orig) + if con.major, err = constraintSegment(segs[0]); err != nil { + return nil, err + } + if con.minor, err = constraintSegment(segs[1]); err != nil { + return nil, err + } + if con.patch, err = constraintSegment(segs[2]); err != nil { + return nil, err + } + } + + if verLen > MaxVersionLen { + return nil, errConstraintParser + } - // The constraintRegex should catch any regex parsing errors. So, - // we should never get here. - return nil, errors.New("constraint parser error") + // The characters in the prerelease are known to be valid. This catches a + // numeric identifier with a leading 0, which is not a valid version. + if pre != "" { + if err = validatePrerelease(pre); err != nil { + return nil, errConstraintParser } + } - cs.con = con - cs.minorDirty = minorDirty - cs.patchDirty = patchDirty - cs.dirty = dirty + c.con = con + return c, nil +} + +// constraintSegment parses a numeric segment of a constraint version under the +// same rules NewVersion applies to one. +func constraintSegment(s string) (uint64, error) { + if !containsOnlyNum(s) { + return 0, errConstraintParser + } - return cs, nil + // A leading 0 is only valid in a version when NewVersion coerces it. + if !CoerceNewVersion && len(s) > 1 && s[0] == '0' { + return 0, errConstraintParser } - // The rest is the special case where an empty string was passed in which - // is equivalent to * or >=0.0.0 - con, err := StrictNewVersion("0.0.0") + v, err := strconv.ParseUint(s, 10, 64) if err != nil { + return 0, errConstraintParser + } + return v, nil +} + +func parseConstraint(c string) (*constraint, error) { + if len(c) == 0 { + // The special case where an empty string was passed in, which is + // equivalent to * or >=0.0.0 + con, err := StrictNewVersion("0.0.0") + if err != nil { - // The constraintRegex should catch any regex parsing errors. So, - // we should never get here. - return nil, errors.New("constraint parser error") + // The version is a constant, so we should never get here. + return nil, errConstraintParser + } + + return &constraint{ + con: con, + orig: c, + origfunc: "", + cf: constraintOps[""], + minorDirty: false, + patchDirty: false, + dirty: true, + }, nil } - cs := &constraint{ - con: con, - orig: c, - origfunc: "", - minorDirty: false, - patchDirty: false, - dirty: true, + s := skipConstraintSpace(c) + if s == "" { + return nil, fmt.Errorf("improper constraint: %q", c) } + + cs, rest, err := scanConstraint(s) + if err != nil { + return nil, err + } + if skipConstraintSpace(rest) != "" { + return nil, fmt.Errorf("improper constraint: %q", c) + } + return cs, nil } // Constraint functions -func constraintNotEqual(v *Version, c *constraint, includePre bool) (bool, error) { +func constraintNotEqual(v *Version, c *constraint, includePre bool) (bool, failReason) { // The existence of prereleases is checked at the group level and passed in. // Exit early if the version has a prerelease but those are to be ignored. if v.Prerelease() != "" && !includePre { - return false, fmt.Errorf("%q is a prerelease version and the constraint is only looking for release versions", v) + return false, reasonPrerelease } if c.dirty { if c.con.Major() != v.Major() { - return true, nil + return true, reasonNone } if c.con.Minor() != v.Minor() && !c.minorDirty { - return true, nil + return true, reasonNone } else if c.minorDirty { - return false, fmt.Errorf("%q is equal to %q", v, c.orig) + return false, reasonEqual } else if c.con.Patch() != v.Patch() && !c.patchDirty { - return true, nil + return true, reasonNone } else if c.patchDirty { // Need to handle prereleases if present if v.Prerelease() != "" || c.con.Prerelease() != "" { eq := comparePrerelease(v.Prerelease(), c.con.Prerelease()) != 0 if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q is equal to %q", v, c.orig) + return false, reasonEqual } - return false, fmt.Errorf("%q is equal to %q", v, c.orig) + return false, reasonEqual } } eq := v.Equal(c.con) if eq { - return false, fmt.Errorf("%q is equal to %q", v, c.orig) + return false, reasonEqual } - return true, nil + return true, reasonNone } -func constraintGreaterThan(v *Version, c *constraint, includePre bool) (bool, error) { +func constraintGreaterThan(v *Version, c *constraint, includePre bool) (bool, failReason) { // The existence of prereleases is checked at the group level and passed in. // Exit early if the version has a prerelease but those are to be ignored. if v.Prerelease() != "" && !includePre { - return false, fmt.Errorf("%q is a prerelease version and the constraint is only looking for release versions", v) + return false, reasonPrerelease } var eq bool @@ -394,72 +669,72 @@ func constraintGreaterThan(v *Version, c *constraint, includePre bool) (bool, er if !c.dirty { eq = v.Compare(c.con) == 1 if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q is less than or equal to %q", v, c.orig) + return false, reasonLessThanEqual } if v.Major() > c.con.Major() { - return true, nil + return true, reasonNone } else if v.Major() < c.con.Major() { - return false, fmt.Errorf("%q is less than or equal to %q", v, c.orig) + return false, reasonLessThanEqual } else if c.minorDirty { // This is a range case such as >11. When the version is something like // 11.1.0 is it not > 11. For that we would need 12 or higher - return false, fmt.Errorf("%q is less than or equal to %q", v, c.orig) + return false, reasonLessThanEqual } else if c.patchDirty { // This is for ranges such as >11.1. A version of 11.1.1 is not greater // which one of 11.2.1 is greater eq = v.Minor() > c.con.Minor() if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q is less than or equal to %q", v, c.orig) + return false, reasonLessThanEqual } // If we have gotten here we are not comparing pre-preleases and can use the // Compare function to accomplish that. eq = v.Compare(c.con) == 1 if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q is less than or equal to %q", v, c.orig) + return false, reasonLessThanEqual } -func constraintLessThan(v *Version, c *constraint, includePre bool) (bool, error) { +func constraintLessThan(v *Version, c *constraint, includePre bool) (bool, failReason) { // The existence of prereleases is checked at the group level and passed in. // Exit early if the version has a prerelease but those are to be ignored. if v.Prerelease() != "" && !includePre { - return false, fmt.Errorf("%q is a prerelease version and the constraint is only looking for release versions", v) + return false, reasonPrerelease } eq := v.Compare(c.con) < 0 if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q is greater than or equal to %q", v, c.orig) + return false, reasonGreaterThanEqual } -func constraintGreaterThanEqual(v *Version, c *constraint, includePre bool) (bool, error) { +func constraintGreaterThanEqual(v *Version, c *constraint, includePre bool) (bool, failReason) { // The existence of prereleases is checked at the group level and passed in. // Exit early if the version has a prerelease but those are to be ignored. if v.Prerelease() != "" && !includePre { - return false, fmt.Errorf("%q is a prerelease version and the constraint is only looking for release versions", v) + return false, reasonPrerelease } eq := v.Compare(c.con) >= 0 if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q is less than %q", v, c.orig) + return false, reasonLessThan } -func constraintLessThanEqual(v *Version, c *constraint, includePre bool) (bool, error) { +func constraintLessThanEqual(v *Version, c *constraint, includePre bool) (bool, failReason) { // The existence of prereleases is checked at the group level and passed in. // Exit early if the version has a prerelease but those are to be ignored. if v.Prerelease() != "" && !includePre { - return false, fmt.Errorf("%q is a prerelease version and the constraint is only looking for release versions", v) + return false, reasonPrerelease } var eq bool @@ -467,18 +742,18 @@ func constraintLessThanEqual(v *Version, c *constraint, includePre bool) (bool, if !c.dirty { eq = v.Compare(c.con) <= 0 if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q is greater than %q", v, c.orig) + return false, reasonGreaterThan } if v.Major() > c.con.Major() { - return false, fmt.Errorf("%q is greater than %q", v, c.orig) + return false, reasonGreaterThan } else if v.Major() == c.con.Major() && v.Minor() > c.con.Minor() && !c.minorDirty { - return false, fmt.Errorf("%q is greater than %q", v, c.orig) + return false, reasonGreaterThan } - return true, nil + return true, reasonNone } // ~*, ~>* --> >= 0.0.0 (any) @@ -487,42 +762,42 @@ func constraintLessThanEqual(v *Version, c *constraint, includePre bool) (bool, // ~1.2, ~1.2.x, ~>1.2, ~>1.2.x --> >=1.2.0, <1.3.0 // ~1.2.3, ~>1.2.3 --> >=1.2.3, <1.3.0 // ~1.2.0, ~>1.2.0 --> >=1.2.0, <1.3.0 -func constraintTilde(v *Version, c *constraint, includePre bool) (bool, error) { +func constraintTilde(v *Version, c *constraint, includePre bool) (bool, failReason) { // The existence of prereleases is checked at the group level and passed in. // Exit early if the version has a prerelease but those are to be ignored. if v.Prerelease() != "" && !includePre { - return false, fmt.Errorf("%q is a prerelease version and the constraint is only looking for release versions", v) + return false, reasonPrerelease } if v.LessThan(c.con) { - return false, fmt.Errorf("%q is less than %q", v, c.orig) + return false, reasonLessThan } // ~0.0.0 is a special case where all constraints are accepted. It's // equivalent to >= 0.0.0. if c.con.Major() == 0 && c.con.Minor() == 0 && c.con.Patch() == 0 && !c.minorDirty && !c.patchDirty { - return true, nil + return true, reasonNone } if v.Major() != c.con.Major() { - return false, fmt.Errorf("%q does not have same major version as %q", v, c.orig) + return false, reasonMajor } if v.Minor() != c.con.Minor() && !c.minorDirty { - return false, fmt.Errorf("%q does not have same major and minor version as %q", v, c.orig) + return false, reasonMajorMinor } - return true, nil + return true, reasonNone } // When there is a .x (dirty) status it automatically opts in to ~. Otherwise // it's a straight = -func constraintTildeOrEqual(v *Version, c *constraint, includePre bool) (bool, error) { +func constraintTildeOrEqual(v *Version, c *constraint, includePre bool) (bool, failReason) { // The existence of prereleases is checked at the group level and passed in. // Exit early if the version has a prerelease but those are to be ignored. if v.Prerelease() != "" && !includePre { - return false, fmt.Errorf("%q is a prerelease version and the constraint is only looking for release versions", v) + return false, reasonPrerelease } if c.dirty { @@ -531,10 +806,10 @@ func constraintTildeOrEqual(v *Version, c *constraint, includePre bool) (bool, e eq := v.Equal(c.con) if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q is not equal to %q", v, c.orig) + return false, reasonNotEqual } // ^* --> (any) @@ -546,16 +821,16 @@ func constraintTildeOrEqual(v *Version, c *constraint, includePre bool) (bool, e // ^0.0.3 --> >=0.0.3 <0.0.4 // ^0.0 --> >=0.0.0 <0.1.0 // ^0 --> >=0.0.0 <1.0.0 -func constraintCaret(v *Version, c *constraint, includePre bool) (bool, error) { +func constraintCaret(v *Version, c *constraint, includePre bool) (bool, failReason) { // The existence of prereleases is checked at the group level and passed in. // Exit early if the version has a prerelease but those are to be ignored. if v.Prerelease() != "" && !includePre { - return false, fmt.Errorf("%q is a prerelease version and the constraint is only looking for release versions", v) + return false, reasonPrerelease } // This less than handles prereleases if v.LessThan(c.con) { - return false, fmt.Errorf("%q is less than %q", v, c.orig) + return false, reasonLessThan } var eq bool @@ -568,35 +843,35 @@ func constraintCaret(v *Version, c *constraint, includePre bool) (bool, error) { // that greater but not within the same major range. eq = v.Major() == c.con.Major() if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q does not have same major version as %q", v, c.orig) + return false, reasonMajor } // ^ when the major is 0 and minor > 0 is >=0.y.z < 0.y+1 if c.con.Major() == 0 && v.Major() > 0 { - return false, fmt.Errorf("%q does not have same major version as %q", v, c.orig) + return false, reasonMajor } // If the con Minor is > 0 it is not dirty if c.con.Minor() > 0 || c.patchDirty { eq = v.Minor() == c.con.Minor() if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q does not have same minor version as %q. Expected minor versions to match when constraint major version is 0", v, c.orig) + return false, reasonCaretMinor } // ^ when the minor is 0 and minor > 0 is =0.0.z if c.con.Minor() == 0 && v.Minor() > 0 { - return false, fmt.Errorf("%q does not have same minor version as %q", v, c.orig) + return false, reasonCaretMinorZero } // At this point the major is 0 and the minor is 0 and not dirty. The patch // is not dirty so we need to check if they are equal. If they are not equal eq = c.con.Patch() == v.Patch() if eq { - return true, nil + return true, reasonNone } - return false, fmt.Errorf("%q does not equal %q. Expect version and constraint to equal when major and minor versions are 0", v, c.orig) + return false, reasonCaretPatch } func isX(x string) bool { @@ -609,6 +884,13 @@ func isX(x string) bool { } func rewriteRange(i string) string { + // A range needs a hyphen between the two versions. Scanning for one is far + // cheaper than running the regex over a constraint that cannot hold a + // range. A hyphen within a prerelease still falls through to the regex. + if !strings.Contains(i, "-") { + return i + } + m := constraintRangeRegex.FindAllStringSubmatch(i, -1) if m == nil { return i diff --git a/constraints_fuzz_test.go b/constraints_fuzz_test.go new file mode 100644 index 0000000..62f3e0c --- /dev/null +++ b/constraints_fuzz_test.go @@ -0,0 +1,318 @@ +package semver + +import ( + "errors" + "fmt" + "regexp" + "strings" + "testing" +) + +// This file keeps the regular expression based constraint parser that the hand +// written scanner replaced. It is only ever compiled into the tests. +// FuzzNewConstraintDifferential runs the two against each other so that the +// scanner stays behaviour preserving: the same inputs are accepted, the parsed +// constraints hold the same values, and checking a version against them gives +// the same answer. + +const refCvRegex string = `v?([0-9|x|X|\*]+)(\.[0-9|x|X|\*]+)?(\.[0-9|x|X|\*]+)?` + + `(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + + `(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + +var ( + refConstraintRegex *regexp.Regexp + refConstraintRangeRegex *regexp.Regexp + refFindConstraintRegex *regexp.Regexp + refValidConstraintRegex *regexp.Regexp +) + +func init() { + ops := `=||!=|>|<|>=|=>|<=|=<|~|~>|\^` + + refConstraintRegex = regexp.MustCompile(fmt.Sprintf( + `^\s*(%s)\s*(%s)\s*$`, + ops, + refCvRegex)) + + refConstraintRangeRegex = regexp.MustCompile(fmt.Sprintf( + `\s*(%s)\s+-\s+(%s)\s*`, + refCvRegex, refCvRegex)) + + refFindConstraintRegex = regexp.MustCompile(fmt.Sprintf( + `(%s)\s*(%s)`, + ops, + refCvRegex)) + + refValidConstraintRegex = regexp.MustCompile(fmt.Sprintf( + `^(\s*(%s)\s*(%s)\s*)((?:\s+|,\s*)(%s)\s*(%s)\s*)*$`, + ops, + refCvRegex, + ops, + refCvRegex)) +} + +func refNewConstraint(c string) (*Constraints, error) { + if len(c) > MaxConstraintLen { + return nil, ErrConstraintTooLong + } + + // Rewrite - ranges into a comparison operation. + c = refRewriteRange(c) + + ors := strings.Split(c, "||") + if len(ors) > MaxConstraintGroups { + return nil, ErrTooManyConstraintGroups + } + lenors := len(ors) + or := make([][]*constraint, lenors) + hasPre := make([]bool, lenors) + for k, v := range ors { + // Validate the segment + if !refValidConstraintRegex.MatchString(v) { + return nil, fmt.Errorf("improper constraint: %q", v) + } + + cs := refFindConstraintRegex.FindAllString(v, -1) + if cs == nil { + cs = append(cs, v) + } + result := make([]*constraint, len(cs)) + for i, s := range cs { + pc, err := refParseConstraint(s) + if err != nil { + return nil, err + } + + if pc.con.pre != "" { + hasPre[k] = true + } + + result[i] = pc + } + or[k] = result + } + + o := &Constraints{ + constraints: or, + containsPre: hasPre, + } + return o, nil +} + +func refParseConstraint(c string) (*constraint, error) { + if len(c) > 0 { + m := refConstraintRegex.FindStringSubmatch(c) + if m == nil { + return nil, fmt.Errorf("improper constraint: %q", c) + } + + cs := &constraint{ + orig: m[2], + origfunc: m[1], + cf: constraintOps[m[1]], + } + + ver := m[2] + minorDirty := false + patchDirty := false + dirty := false + if isX(m[3]) || m[3] == "" { + ver = fmt.Sprintf("0.0.0%s", m[6]) + dirty = true + } else if isX(strings.TrimPrefix(m[4], ".")) || m[4] == "" { + minorDirty = true + dirty = true + ver = fmt.Sprintf("%s.0.0%s", m[3], m[6]) + } else if isX(strings.TrimPrefix(m[5], ".")) || m[5] == "" { + dirty = true + patchDirty = true + ver = fmt.Sprintf("%s%s.0%s", m[3], m[4], m[6]) + } + + con, err := NewVersion(ver) + if err != nil { + return nil, errors.New("constraint parser error") + } + + cs.con = con + cs.minorDirty = minorDirty + cs.patchDirty = patchDirty + cs.dirty = dirty + + return cs, nil + } + + con, err := StrictNewVersion("0.0.0") + if err != nil { + return nil, errors.New("constraint parser error") + } + + cs := &constraint{ + con: con, + orig: c, + origfunc: "", + cf: constraintOps[""], + minorDirty: false, + patchDirty: false, + dirty: true, + } + return cs, nil +} + +func refRewriteRange(i string) string { + m := refConstraintRangeRegex.FindAllStringSubmatch(i, -1) + if m == nil { + return i + } + o := i + for _, v := range m { + t := fmt.Sprintf(">= %s, <= %s ", v[1], v[11]) + o = strings.Replace(o, v[0], t, 1) + } + + return o +} + +// sameConstraints compares two parsed constraint sets field by field. The +// original string held on each constraint version is not compared: it is not +// reachable through the public API and the two parsers build it differently. +func sameConstraints(a, b *Constraints) error { + if len(a.constraints) != len(b.constraints) { + return fmt.Errorf("%d or groups, want %d", len(a.constraints), len(b.constraints)) + } + for i := range a.constraints { + if a.containsPre[i] != b.containsPre[i] { + return fmt.Errorf("group %d containsPre %t, want %t", i, a.containsPre[i], b.containsPre[i]) + } + if len(a.constraints[i]) != len(b.constraints[i]) { + return fmt.Errorf("group %d has %d constraints, want %d", i, + len(a.constraints[i]), len(b.constraints[i])) + } + for j := range a.constraints[i] { + x, y := a.constraints[i][j], b.constraints[i][j] + if x.orig != y.orig || x.origfunc != y.origfunc { + return fmt.Errorf("constraint %d.%d is %q%q, want %q%q", i, j, + x.origfunc, x.orig, y.origfunc, y.orig) + } + if x.dirty != y.dirty || x.minorDirty != y.minorDirty || x.patchDirty != y.patchDirty { + return fmt.Errorf("constraint %d.%d dirty %t/%t/%t, want %t/%t/%t", i, j, + x.dirty, x.minorDirty, x.patchDirty, y.dirty, y.minorDirty, y.patchDirty) + } + if !sameConstraintVersion(x.con, y.con) { + return fmt.Errorf("constraint %d.%d version %#v, want %#v", i, j, x.con, y.con) + } + } + } + return nil +} + +// sameConstraintVersion compares the values of the version a constraint holds. +// The original string is left out: the two parsers build it differently and it +// is not reachable through the public API. +func sameConstraintVersion(a, b *Version) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.major == b.major && a.minor == b.minor && a.patch == b.patch && + a.pre == b.pre && a.metadata == b.metadata +} + +// constraintCorpus holds constraint strings covering the operators, the +// wildcard forms, the separators, and a range of malformed input. +var constraintCorpus = []string{ + "", " ", "*", "x", "X", "1.x", "1.2.x", "x.2.3", "1.x.3", + "=1.2.3", "= 1.2.3", "==1.2.3", "!=1.2.3", ">1.2.3", "<1.2.3", + ">=1.2.3", "=>1.2.3", "<=1.2.3", "=<1.2.3", "~1.2.3", "~>1.2.3", + "^1.2.3", "^0.0.3", "^0.2", "~0.0.0", "v1.2.3", "V1.2.3", + ">=2.1.x, <3.1.0", ">= 2.1.x , < 3.1.0", ">=2.1.x <3.1.0", + "~2.0.0 || =3.1.0", "1.0.0 - 2.0.0", "1.0.0 -2.0.0", "1.0.0- 2.0.0", + "1.2.3-alpha", ">=1.2.3-alpha.1", "1.2.3+meta", "=1.2.3-alpha+meta", + "1.2.3-", "1.2.3+", "1.2.3-alpha.", "1.2.3.4", "1.2.3.4.5.6", + "1|2", "1.2|3", "01.2.3", "1.02.3", "1.2.3-01", + ">= 1.2 || < 3, > 4", ",1.0", "1.0,", "1.0,,2.0", "1.0<2.0", + "> = 1.2", ">=", "~", "^", "foo", "lorem ipsum", "1.2.3 ", " 1.2.3", + "18446744073709551616.0.0", "1.18446744073709551616", "x.18446744073709551616", + "|| 1.2.3", "1.2.3 ||", "1.2.3 || || 2.0.0", "*.*.*", "1.*.3", +} + +func FuzzNewConstraintDifferential(f *testing.F) { + for _, c := range constraintCorpus { + f.Add(c) + } + for _, v := range versionCorpus { + f.Add(v) + } + + // Versions the two constraint sets are checked against. + var versions []*Version + for _, v := range versionCorpus { + if sv, err := NewVersion(v); err == nil { + versions = append(versions, sv) + } + } + + f.Fuzz(func(t *testing.T, c string) { + // Both parsers build their versions under the rules NewVersion + // applies, so the coercion setting has to be covered too. + for _, coerce := range []bool{true, false} { + CoerceNewVersion = coerce + checkConstraintPair(t, c, coerce, versions) + } + CoerceNewVersion = true + }) +} + +func checkConstraintPair(t *testing.T, c string, coerce bool, versions []*Version) { + t.Helper() + + { + got, gotErr := NewConstraint(c) + want, wantErr := refNewConstraint(c) + + if (gotErr == nil) != (wantErr == nil) { + t.Fatalf("NewConstraint(%q) with coerce=%t: error %v, want %v", c, coerce, gotErr, wantErr) + } + if gotErr != nil { + // Both failed. The messages are allowed to differ, but a + // sentinel error must still be the same sentinel. + if errors.Is(wantErr, ErrConstraintTooLong) && !errors.Is(gotErr, ErrConstraintTooLong) { + t.Fatalf("NewConstraint(%q): error %v, want ErrConstraintTooLong", c, gotErr) + } + if errors.Is(wantErr, ErrTooManyConstraintGroups) && !errors.Is(gotErr, ErrTooManyConstraintGroups) { + t.Fatalf("NewConstraint(%q): error %v, want ErrTooManyConstraintGroups", c, gotErr) + } + return + } + + if err := sameConstraints(got, want); err != nil { + t.Fatalf("NewConstraint(%q) with coerce=%t: %s", c, coerce, err) + } + + if got.String() != want.String() { + t.Fatalf("NewConstraint(%q).String() = %q, want %q", c, got.String(), want.String()) + } + + for _, v := range versions { + for _, pre := range []bool{false, true} { + got.IncludePrerelease = pre + want.IncludePrerelease = pre + + if a, b := got.Check(v), want.Check(v); a != b { + t.Fatalf("NewConstraint(%q).Check(%s) with prerelease %t = %t, want %t", + c, v, pre, a, b) + } + + a, aerrs := got.Validate(v) + b, berrs := want.Validate(v) + if a != b { + t.Fatalf("NewConstraint(%q).Validate(%s) with prerelease %t = %t, want %t", + c, v, pre, a, b) + } + if fmt.Sprint(aerrs) != fmt.Sprint(berrs) { + t.Fatalf("NewConstraint(%q).Validate(%s) with prerelease %t errors %v, want %v", + c, v, pre, aerrs, berrs) + } + } + } + } +} diff --git a/constraints_test.go b/constraints_test.go index fe2c14b..949619b 100644 --- a/constraints_test.go +++ b/constraints_test.go @@ -792,6 +792,7 @@ func TestConstraintsValidate(t *testing.T) { constraint, version, msg string }{ {"2.x", "1.2.3", `"1.2.3" is less than "2.x"`}, + {"=2.0.0", "2.0.1", `"2.0.1" is not equal to "2.0.0"`}, {"2", "1.2.3", `"1.2.3" is less than "2"`}, {"= 2.0", "1.2.3", `"1.2.3" is less than "2.0"`}, {"!=4.1", "4.1.0", `"4.1.0" is equal to "4.1"`}, diff --git a/doc.go b/doc.go index 74f97ca..2ba57fb 100644 --- a/doc.go +++ b/doc.go @@ -46,6 +46,11 @@ For example, sort.Sort(semver.Collection(vs)) +The `Sort` function does the same thing without going through the `sort` +package, which saves an interface dispatch on each comparison. For example, + + semver.Sort(vs) + # Checking Version Constraints and Comparing Versions There are two methods for comparing versions. One uses comparison methods on diff --git a/version.go b/version.go index 84544f4..85ee909 100644 --- a/version.go +++ b/version.go @@ -1,22 +1,15 @@ package semver import ( - "bytes" "database/sql/driver" "encoding/json" "errors" "fmt" "math" - "regexp" "strconv" "strings" ) -// The compiled version of the regex created at init() is cached here so it -// only needs to be created once. -var versionRegex *regexp.Regexp -var looseVersionRegex *regexp.Regexp - // CoerceNewVersion sets if leading 0's are allowd in the version part. Leading 0's are // not allowed in a valid semantic version. When set to true, NewVersion will coerce // leading 0's into a valid version. @@ -65,19 +58,6 @@ var ( // against unbounded input causing excessive memory allocations during parsing. const MaxVersionLen = 256 -// semVerRegex is the regular expression used to parse a semantic version. -// This is not the official regex from the semver spec. It has been modified to allow for loose handling -// where versions like 2.1 are detected. -const semVerRegex string = `v?(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?` + - `(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?` + - `(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?` - -// looseSemVerRegex is a regular expression that lets invalid semver expressions through -// with enough detail that certain errors can be checked for. -const looseSemVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` + - `(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + - `(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` - // Version represents a single semantic version. type Version struct { major, minor, patch uint64 @@ -86,11 +66,6 @@ type Version struct { original string } -func init() { - versionRegex = regexp.MustCompile("^" + semVerRegex + "$") - looseVersionRegex = regexp.MustCompile("^" + looseSemVerRegex + "$") -} - const ( num string = "0123456789" allowed string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-" + num @@ -113,31 +88,36 @@ func StrictNewVersion(v string) (*Version, error) { return nil, ErrVersionTooLong } - // Split the parts into [0]major, [1]minor, and [2]patch,prerelease,build - parts := strings.SplitN(v, ".", 3) - if len(parts) != 3 { - return nil, ErrInvalidSemVer + // Split the parts into [0]major, [1]minor, and [2]patch,prerelease,build. + // The parts are walked in place so that no slice is allocated to hold them. + var parts [3]string + rest := v + for i := 0; i < 2; i++ { + j := strings.IndexByte(rest, '.') + if j < 0 { + return nil, ErrInvalidSemVer + } + parts[i], rest = rest[:j], rest[j+1:] } + parts[2] = rest sv := &Version{ original: v, } // Extract build metadata - if strings.Contains(parts[2], "+") { - extra := strings.SplitN(parts[2], "+", 2) - sv.metadata = extra[1] - parts[2] = extra[0] + if i := strings.IndexByte(parts[2], '+'); i >= 0 { + sv.metadata = parts[2][i+1:] + parts[2] = parts[2][:i] if err := validateMetadata(sv.metadata); err != nil { return nil, err } } // Extract build prerelease - if strings.Contains(parts[2], "-") { - extra := strings.SplitN(parts[2], "-", 2) - sv.pre = extra[1] - parts[2] = extra[0] + if i := strings.IndexByte(parts[2], '-'); i >= 0 { + sv.pre = parts[2][i+1:] + parts[2] = parts[2][:i] if err := validatePrerelease(sv.pre); err != nil { return nil, err } @@ -146,7 +126,7 @@ func StrictNewVersion(v string) (*Version, error) { // Validate the number segments are valid. This includes only having positive // numbers and no leading 0's. for _, p := range parts { - if !containsOnly(p, num) { + if !containsOnlyNum(p) { return nil, ErrInvalidCharacters } @@ -186,127 +166,221 @@ func NewVersion(v string) (*Version, error) { if CoerceNewVersion { return coerceNewVersion(v) } - m := versionRegex.FindStringSubmatch(v) - if m == nil { + return exactNewVersion(v) +} - // Disabling detailed errors is first so that it is in the fast path. - if !DetailedNewVersionErrors { - return nil, ErrInvalidSemVer +// parseVersionParts splits a SemVer-ish version into its numeric segments, +// prerelease, and metadata. Parsing here does not use RegEx in order to +// increase performance and reduce allocations. +// +// The shape accepted is the loose one: a leading v, one to three numeric +// segments, then optional dot separated prerelease and metadata identifiers +// made up of the characters [0-9A-Za-z-]. Rules that a valid semantic version +// adds on top of that, such as numeric values not having a leading 0, are left +// to the caller since NewVersion coerces some of them. n is the number of +// numeric segments found. ok is false when v does not fit the shape at all. +func parseVersionParts(v string) (segs [3]string, n int, pre, metadata string, ok bool) { + s := v + if len(s) > 0 && s[0] == 'v' { + s = s[1:] + } + + // Metadata is everything following the first +. It is separated first + // because a - is a valid character within metadata. + if i := strings.IndexByte(s, '+'); i >= 0 { + metadata = s[i+1:] + s = s[:i] + if !validIdentifiers(metadata) { + return segs, 0, "", "", false } + } - // Check for specific errors with the semver string and return a more detailed - // error. - m = looseVersionRegex.FindStringSubmatch(v) - if m == nil { - return nil, ErrInvalidSemVer + // The prerelease is everything following the first - that remains once + // the metadata has been removed. + if i := strings.IndexByte(s, '-'); i >= 0 { + pre = s[i+1:] + s = s[:i] + if !validIdentifiers(pre) { + return segs, 0, "", "", false } - err := validateVersion(m) - if err != nil { - return nil, err + } + + // What remains are the major, minor, and patch segments. The segments are + // all checked before any is parsed so that an invalid segment is reported + // ahead of a numeric one that is out of range. + for { + var seg string + more := false + if i := strings.IndexByte(s, '.'); i >= 0 { + seg, s, more = s[:i], s[i+1:], true + } else { + seg, s = s, "" + } + + if n > 2 || seg == "" || !containsOnlyNum(seg) { + return segs, 0, "", "", false + } + segs[n] = seg + n++ + + if !more { + return segs, n, pre, metadata, true + } + } +} + +// validIdentifiers reports if s is a series of dot separated identifiers made +// up of the characters allowed in a prerelease or metadata string. Identifiers +// must not be empty. +func validIdentifiers(s string) bool { + for { + var part string + more := false + if i := strings.IndexByte(s, '.'); i >= 0 { + part, s, more = s[:i], s[i+1:], true + } else { + part, s = s, "" } + + if part == "" || !containsOnlyAllowed(part) { + return false + } + + if !more { + return true + } + } +} + +// coerceNewVersion parses a SemVer-ish version, coercing versions such as 1 or +// 1.2 into a full version. Leading 0's on the numeric segments are allowed. +func coerceNewVersion(v string) (*Version, error) { + segs, n, pre, metadata, ok := parseVersionParts(v) + if !ok { return nil, ErrInvalidSemVer } sv := &Version{ - metadata: m[5], - pre: m[4], + metadata: metadata, + pre: pre, original: v, } var err error - sv.major, err = strconv.ParseUint(m[1], 10, 64) - if err != nil { + if sv.major, err = strconv.ParseUint(segs[0], 10, 64); err != nil { return nil, fmt.Errorf("error parsing version segment: %w", err) } - if m[2] != "" { - sv.minor, err = strconv.ParseUint(m[2], 10, 64) - if err != nil { + if n > 1 { + if sv.minor, err = strconv.ParseUint(segs[1], 10, 64); err != nil { return nil, fmt.Errorf("error parsing version segment: %w", err) } - } else { - sv.minor = 0 } - if m[3] != "" { - sv.patch, err = strconv.ParseUint(m[3], 10, 64) - if err != nil { + if n > 2 { + if sv.patch, err = strconv.ParseUint(segs[2], 10, 64); err != nil { return nil, fmt.Errorf("error parsing version segment: %w", err) } - } else { - sv.patch = 0 } - // Perform some basic due diligence on the extra parts to ensure they are - // valid. - + // The characters in the prerelease are already known to be valid. This + // catches the numeric identifiers that have a leading 0. if sv.pre != "" { if err = validatePrerelease(sv.pre); err != nil { return nil, err } } - if sv.metadata != "" { - if err = validateMetadata(sv.metadata); err != nil { - return nil, err - } - } - return sv, nil } -func coerceNewVersion(v string) (*Version, error) { - m := looseVersionRegex.FindStringSubmatch(v) - if m == nil { +// exactNewVersion parses a version without coercing leading 0's on the numeric +// segments or on the numeric identifiers of a prerelease. Missing minor and +// patch segments are still filled in with 0. +func exactNewVersion(v string) (*Version, error) { + segs, n, pre, metadata, ok := parseVersionParts(v) + if !ok { return nil, ErrInvalidSemVer } + // The loose shape allows leading 0's where a valid semantic version does + // not. Detect that before anything is parsed so that the fast path when + // detailed errors are disabled does no extra work. + valid := true + for i := 0; i < n; i++ { + if len(segs[i]) > 1 && segs[i][0] == '0' { + valid = false + break + } + } + if valid && pre != "" && validatePrerelease(pre) != nil { + valid = false + } + + if !valid { + + // Disabling detailed errors is first so that it is in the fast path. + if !DetailedNewVersionErrors { + return nil, ErrInvalidSemVer + } + + // Check for specific errors with the semver string and return a more + // detailed error. + return nil, detailedVersionError(segs, n, pre, metadata) + } + sv := &Version{ - metadata: m[8], - pre: m[5], + metadata: metadata, + pre: pre, original: v, } var err error - sv.major, err = strconv.ParseUint(m[1], 10, 64) - if err != nil { + if sv.major, err = strconv.ParseUint(segs[0], 10, 64); err != nil { return nil, fmt.Errorf("error parsing version segment: %w", err) } - if m[2] != "" { - sv.minor, err = strconv.ParseUint(strings.TrimPrefix(m[2], "."), 10, 64) - if err != nil { + if n > 1 { + if sv.minor, err = strconv.ParseUint(segs[1], 10, 64); err != nil { return nil, fmt.Errorf("error parsing version segment: %w", err) } - } else { - sv.minor = 0 } - if m[3] != "" { - sv.patch, err = strconv.ParseUint(strings.TrimPrefix(m[3], "."), 10, 64) - if err != nil { + if n > 2 { + if sv.patch, err = strconv.ParseUint(segs[2], 10, 64); err != nil { return nil, fmt.Errorf("error parsing version segment: %w", err) } - } else { - sv.patch = 0 } - // Perform some basic due diligence on the extra parts to ensure they are - // valid. + return sv, nil +} - if sv.pre != "" { - if err = validatePrerelease(sv.pre); err != nil { - return nil, err +// detailedVersionError reports the first problem found with a version that is +// shaped like a semantic version but is not a valid one. Problems are looked +// for in the order the parts appear in the version. +func detailedVersionError(segs [3]string, n int, pre, metadata string) error { + for i := 0; i < n; i++ { + if len(segs[i]) > 1 && segs[i][0] == '0' { + return ErrSegmentStartsZero + } + if _, err := strconv.ParseUint(segs[i], 10, 64); err != nil { + return fmt.Errorf("error parsing version segment: %w", err) } } - if sv.metadata != "" { - if err = validateMetadata(sv.metadata); err != nil { - return nil, err + if pre != "" { + if err := validatePrerelease(pre); err != nil { + return err } } - return sv, nil + if metadata != "" { + if err := validateMetadata(metadata); err != nil { + return err + } + } + + return ErrInvalidSemVer } // New creates a new instance of Version with each of the parts passed in as @@ -344,17 +418,27 @@ func MustParse(v string) *Version { // don't contain a leading v per the spec. Instead it's optional on // implementation. func (v Version) String() string { - var buf bytes.Buffer - - fmt.Fprintf(&buf, "%d.%d.%d", v.major, v.minor, v.patch) + // The buffer starts on the stack. It is large enough for three uint64 + // segments and their separators, so only a version with a prerelease or + // metadata can grow it. + var b [64]byte + buf := b[:0] + + buf = strconv.AppendUint(buf, v.major, 10) + buf = append(buf, '.') + buf = strconv.AppendUint(buf, v.minor, 10) + buf = append(buf, '.') + buf = strconv.AppendUint(buf, v.patch, 10) if v.pre != "" { - fmt.Fprintf(&buf, "-%s", v.pre) + buf = append(buf, '-') + buf = append(buf, v.pre...) } if v.metadata != "" { - fmt.Fprintf(&buf, "+%s", v.metadata) + buf = append(buf, '+') + buf = append(buf, v.metadata...) } - return buf.String() + return string(buf) } // Original returns the original value passed in to be parsed. @@ -390,7 +474,7 @@ func (v Version) Metadata() string { // originalVPrefix returns the original 'v' prefix if any. func (v Version) originalVPrefix() string { // Note, only lowercase v is supported as a prefix by the parser. - if v.original != "" && v.original[:1] == "v" { + if v.original != "" && v.original[0] == 'v' { return v.original[:1] } return "" @@ -690,37 +774,15 @@ func compareSegment(v, o uint64) int { } func comparePrerelease(v, o string) int { - // split the prelease versions by their part. The separator, per the spec, - // is a . - sparts := strings.Split(v, ".") - oparts := strings.Split(o, ".") - - // Find the longer length of the parts to know how many loop iterations to - // go through. - slen := len(sparts) - olen := len(oparts) - - l := slen - if olen > slen { - l = olen - } - - // Iterate over each part of the prereleases to compare the differences. - for i := 0; i < l; i++ { - // Since the lentgh of the parts can be different we need to create - // a placeholder. This is to avoid out of bounds issues. - stemp := "" - if i < slen { - stemp = sparts[i] - } - - otemp := "" - if i < olen { - otemp = oparts[i] - } - - d := comparePrePart(stemp, otemp) - if d != 0 { + // Walk the dot separated parts of both prereleases. The separator, per the + // spec, is a . The parts are walked in place so that no slices are + // allocated to hold them. + for v != "" || o != "" { + var sp, op string + sp, v = nextPart(v) + op, o = nextPart(o) + + if d := comparePrePart(sp, op); d != 0 { return d } } @@ -731,6 +793,15 @@ func comparePrerelease(v, o string) int { return 0 } +// nextPart returns the leading dot separated segment of s along with the +// remainder of s following the dot. +func nextPart(s string) (part, rest string) { + if i := strings.IndexByte(s, '.'); i >= 0 { + return s[:i], s[i+1:] + } + return s, "" +} + func comparePrePart(s, o string) int { // Fastpath if they are equal if s == o { @@ -760,19 +831,19 @@ func comparePrePart(s, o string) int { // have precedence over alphanum. Parsing as Uints because negative numbers // are ignored. - oi, n1 := strconv.ParseUint(o, 10, 64) - si, n2 := strconv.ParseUint(s, 10, 64) + oi, onum := parseIdentifierNum(o) + si, snum := parseIdentifierNum(s) // The case where both are strings compare the strings - if n1 != nil && n2 != nil { + if !onum && !snum { if s > o { return 1 } return -1 - } else if n1 != nil { + } else if !onum { // o is a string and s is a number return -1 - } else if n2 != nil { + } else if !snum { // s is a string and o is a number return 1 } @@ -783,97 +854,93 @@ func comparePrePart(s, o string) int { return -1 } -// Like strings.ContainsAny but does an only instead of any. -func containsOnly(s string, comp string) bool { - return strings.IndexFunc(s, func(r rune) bool { - return !strings.ContainsRune(comp, r) - }) == -1 -} +// parseIdentifierNum reports if a prerelease identifier is a number and, when +// it is, its value. An identifier that would overflow a uint64 is not treated +// as a number. Parsing here does not use strconv so that an identifier that is +// not a number costs no allocation. +func parseIdentifierNum(s string) (uint64, bool) { + if s == "" { + return 0, false + } -// From the spec, "Identifiers MUST comprise only -// ASCII alphanumerics and hyphen [0-9A-Za-z-]. Identifiers MUST NOT be empty. -// Numeric identifiers MUST NOT include leading zeroes.". These segments can -// be dot separated. -func validatePrerelease(p string) error { - eparts := strings.Split(p, ".") - for _, p := range eparts { - if p == "" { - return ErrInvalidPrerelease - } else if containsOnly(p, num) { - if len(p) > 1 && p[0] == '0' { - return ErrSegmentStartsZero - } - } else if !containsOnly(p, allowed) { - return ErrInvalidPrerelease + var n uint64 + for i := 0; i < len(s); i++ { + if !numChars[s[i]] { + return 0, false + } + d := uint64(s[i] - '0') + if n > (math.MaxUint64-d)/10 { + return 0, false } + n = n*10 + d } - return nil + return n, true } -// From the spec, "Build metadata MAY be denoted by -// appending a plus sign and a series of dot separated identifiers immediately -// following the patch or pre-release version. Identifiers MUST comprise only -// ASCII alphanumerics and hyphen [0-9A-Za-z-]. Identifiers MUST NOT be empty." -func validateMetadata(m string) error { - eparts := strings.Split(m, ".") - for _, p := range eparts { - if p == "" { - return ErrInvalidMetadata - } else if !containsOnly(p, allowed) { - return ErrInvalidMetadata - } +// allowedChars and numChars are lookup tables for the characters allowed in +// the identifier and numeric portions of a version. +var allowedChars, numChars [256]bool + +func init() { + for i := 0; i < len(allowed); i++ { + allowedChars[allowed[i]] = true + } + for i := 0; i < len(num); i++ { + numChars[num[i]] = true } - return nil } -// validateVersion checks for common validation issues but may not catch all errors -func validateVersion(m []string) error { - var err error - var v string - if m[1] != "" { - if len(m[1]) > 1 && m[1][0] == '0' { - return ErrSegmentStartsZero - } - _, err = strconv.ParseUint(m[1], 10, 64) - if err != nil { - return fmt.Errorf("error parsing version segment: %w", err) +// containsOnlyNum reports if s is made up only of the digits 0-9. +func containsOnlyNum(s string) bool { + for i := 0; i < len(s); i++ { + if !numChars[s[i]] { + return false } } + return true +} - if m[2] != "" { - v = strings.TrimPrefix(m[2], ".") - if len(v) > 1 && v[0] == '0' { - return ErrSegmentStartsZero - } - _, err = strconv.ParseUint(v, 10, 64) - if err != nil { - return fmt.Errorf("error parsing version segment: %w", err) +// containsOnlyAllowed reports if s is made up only of the characters valid in +// a prerelease or metadata identifier. +func containsOnlyAllowed(s string) bool { + for i := 0; i < len(s); i++ { + if !allowedChars[s[i]] { + return false } } + return true +} - if m[3] != "" { - v = strings.TrimPrefix(m[3], ".") - if len(v) > 1 && v[0] == '0' { - return ErrSegmentStartsZero - } - _, err = strconv.ParseUint(v, 10, 64) - if err != nil { - return fmt.Errorf("error parsing version segment: %w", err) - } +// From the spec, "Identifiers MUST comprise only +// ASCII alphanumerics and hyphen [0-9A-Za-z-]. Identifiers MUST NOT be empty. +// Numeric identifiers MUST NOT include leading zeroes.". These segments can +// be dot separated. +func validatePrerelease(p string) error { + if !validIdentifiers(p) { + return ErrInvalidPrerelease } - if m[5] != "" { - if err = validatePrerelease(m[5]); err != nil { - return err + // The identifiers are known to be valid and non-empty. Numeric identifiers + // must not have a leading 0. + for p != "" { + var part string + part, p = nextPart(p) + if len(part) > 1 && part[0] == '0' && containsOnlyNum(part) { + return ErrSegmentStartsZero } } - if m[8] != "" { - if err = validateMetadata(m[8]); err != nil { - return err - } - } + return nil +} +// From the spec, "Build metadata MAY be denoted by +// appending a plus sign and a series of dot separated identifiers immediately +// following the patch or pre-release version. Identifiers MUST comprise only +// ASCII alphanumerics and hyphen [0-9A-Za-z-]. Identifiers MUST NOT be empty." +func validateMetadata(m string) error { + if !validIdentifiers(m) { + return ErrInvalidMetadata + } return nil } diff --git a/version_fuzz_test.go b/version_fuzz_test.go new file mode 100644 index 0000000..7343158 --- /dev/null +++ b/version_fuzz_test.go @@ -0,0 +1,380 @@ +package semver + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "testing" +) + +// This file keeps the regular expression based implementation of NewVersion +// that the hand written parser replaced. It is only ever compiled into the +// tests. FuzzNewVersion runs the two against each other so that the parser +// stays behaviour preserving, including which error is returned. + +// refSemVerRegex is the regular expression that was used to parse a semantic +// version. This is not the official regex from the semver spec. It has been +// modified to allow for loose handling where versions like 2.1 are detected. +const refSemVerRegex string = `v?(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?` + + `(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?` + + `(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?` + +// refLooseSemVerRegex is a regular expression that lets invalid semver +// expressions through with enough detail that certain errors can be checked +// for. +const refLooseSemVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` + + `(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + + `(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + +var ( + refVersionRegex = regexp.MustCompile("^" + refSemVerRegex + "$") + refLooseVersionRegex = regexp.MustCompile("^" + refLooseSemVerRegex + "$") +) + +func refNewVersion(v string) (*Version, error) { + if len(v) > MaxVersionLen { + return nil, ErrVersionTooLong + } + if CoerceNewVersion { + return refCoerceNewVersion(v) + } + m := refVersionRegex.FindStringSubmatch(v) + if m == nil { + + // Disabling detailed errors is first so that it is in the fast path. + if !DetailedNewVersionErrors { + return nil, ErrInvalidSemVer + } + + // Check for specific errors with the semver string and return a more detailed + // error. + m = refLooseVersionRegex.FindStringSubmatch(v) + if m == nil { + return nil, ErrInvalidSemVer + } + err := refValidateVersion(m) + if err != nil { + return nil, err + } + return nil, ErrInvalidSemVer + } + + sv := &Version{ + metadata: m[5], + pre: m[4], + original: v, + } + + var err error + sv.major, err = strconv.ParseUint(m[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing version segment: %w", err) + } + + if m[2] != "" { + sv.minor, err = strconv.ParseUint(m[2], 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing version segment: %w", err) + } + } else { + sv.minor = 0 + } + + if m[3] != "" { + sv.patch, err = strconv.ParseUint(m[3], 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing version segment: %w", err) + } + } else { + sv.patch = 0 + } + + // Perform some basic due diligence on the extra parts to ensure they are + // valid. + + if sv.pre != "" { + if err = validatePrerelease(sv.pre); err != nil { + return nil, err + } + } + + if sv.metadata != "" { + if err = validateMetadata(sv.metadata); err != nil { + return nil, err + } + } + + return sv, nil +} + +func refCoerceNewVersion(v string) (*Version, error) { + m := refLooseVersionRegex.FindStringSubmatch(v) + if m == nil { + return nil, ErrInvalidSemVer + } + + sv := &Version{ + metadata: m[8], + pre: m[5], + original: v, + } + + var err error + sv.major, err = strconv.ParseUint(m[1], 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing version segment: %w", err) + } + + if m[2] != "" { + sv.minor, err = strconv.ParseUint(strings.TrimPrefix(m[2], "."), 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing version segment: %w", err) + } + } else { + sv.minor = 0 + } + + if m[3] != "" { + sv.patch, err = strconv.ParseUint(strings.TrimPrefix(m[3], "."), 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing version segment: %w", err) + } + } else { + sv.patch = 0 + } + + // Perform some basic due diligence on the extra parts to ensure they are + // valid. + + if sv.pre != "" { + if err = validatePrerelease(sv.pre); err != nil { + return nil, err + } + } + + if sv.metadata != "" { + if err = validateMetadata(sv.metadata); err != nil { + return nil, err + } + } + + return sv, nil +} + +// refValidateVersion checks for common validation issues but may not catch all errors +func refValidateVersion(m []string) error { + var err error + var v string + if m[1] != "" { + if len(m[1]) > 1 && m[1][0] == '0' { + return ErrSegmentStartsZero + } + _, err = strconv.ParseUint(m[1], 10, 64) + if err != nil { + return fmt.Errorf("error parsing version segment: %w", err) + } + } + + if m[2] != "" { + v = strings.TrimPrefix(m[2], ".") + if len(v) > 1 && v[0] == '0' { + return ErrSegmentStartsZero + } + _, err = strconv.ParseUint(v, 10, 64) + if err != nil { + return fmt.Errorf("error parsing version segment: %w", err) + } + } + + if m[3] != "" { + v = strings.TrimPrefix(m[3], ".") + if len(v) > 1 && v[0] == '0' { + return ErrSegmentStartsZero + } + _, err = strconv.ParseUint(v, 10, 64) + if err != nil { + return fmt.Errorf("error parsing version segment: %w", err) + } + } + + if m[5] != "" { + if err = validatePrerelease(m[5]); err != nil { + return err + } + } + + if m[8] != "" { + if err = validateMetadata(m[8]); err != nil { + return err + } + } + + return nil +} + +// sameErr reports if two errors are the same for the purposes of the +// differential test. Sentinel errors must be identical. Wrapped errors, which +// only come from strconv, must carry the same message. +func sameErr(a, b error) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + if errors.Is(a, b) || errors.Is(b, a) { + return true + } + return a.Error() == b.Error() +} + +func sameVersion(a, b *Version) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.major == b.major && a.minor == b.minor && a.patch == b.patch && + a.pre == b.pre && a.metadata == b.metadata && a.original == b.original +} + +// versionCorpus holds inputs that exercise the interesting edges of the +// parser. It seeds the differential fuzz targets. +var versionCorpus = []string{ + "1.2.3", "v1.2.3", "1.2", "1", "v1", "", + "1.2.3-alpha", "1.2.3-alpha.1", "1.2.3-alpha.beta", "1.2.3-0abc123", + "1.2.3+meta", "1.2.3+meta.data", "1.2.3-alpha.1+meta.data", + "01.2.3", "1.02.3", "1.2.03", "1.2.3-01", "1.2.3-alpha.01", + "1.2.3-", "1.2.3+", "1.2.3-alpha.", "1.2.3+meta.", "1.2.3-alpha..1", + "1.2.", "1.", ".1.2", "1..2", "1.2.3.4", "-1.2.3", "+1.2.3", + "1.2.3-alpha_beta", "1.2.3 ", " 1.2.3", "1.2 .3", "\n1.2", + "18446744073709551615.0.0", "18446744073709551616.0.0", + "0.0.0", "0.0.0-0", "20221209-update-renovatejson-v4", + "9.8.7+meta+meta", "1.2.31----RC-SNAPSHOT.12.09.1--.12+788", + "v1.2.0-x.Y.0+metadata-width-hypen", "1.2-5", "1-2.3", "1.2+3-4", + "x", "1.x", "*", "v", "vv1.2.3", +} + +// refStrictNewVersion is the splitting StrictNewVersion used before it walked +// its parts in place. +func refStrictNewVersion(v string) (*Version, error) { + if len(v) == 0 { + return nil, ErrEmptyString + } + + if len(v) > MaxVersionLen { + return nil, ErrVersionTooLong + } + + // Split the parts into [0]major, [1]minor, and [2]patch,prerelease,build + parts := strings.SplitN(v, ".", 3) + if len(parts) != 3 { + return nil, ErrInvalidSemVer + } + + sv := &Version{ + original: v, + } + + // Extract build metadata + if strings.Contains(parts[2], "+") { + extra := strings.SplitN(parts[2], "+", 2) + sv.metadata = extra[1] + parts[2] = extra[0] + if err := validateMetadata(sv.metadata); err != nil { + return nil, err + } + } + + // Extract build prerelease + if strings.Contains(parts[2], "-") { + extra := strings.SplitN(parts[2], "-", 2) + sv.pre = extra[1] + parts[2] = extra[0] + if err := validatePrerelease(sv.pre); err != nil { + return nil, err + } + } + + // Validate the number segments are valid. This includes only having positive + // numbers and no leading 0's. + for _, p := range parts { + if !containsOnlyNum(p) { + return nil, ErrInvalidCharacters + } + + if len(p) > 1 && p[0] == '0' { + return nil, ErrSegmentStartsZero + } + } + + // Extract major, minor, and patch + var err error + sv.major, err = strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return nil, err + } + + sv.minor, err = strconv.ParseUint(parts[1], 10, 64) + if err != nil { + return nil, err + } + + sv.patch, err = strconv.ParseUint(parts[2], 10, 64) + if err != nil { + return nil, err + } + + return sv, nil +} + +func FuzzStrictNewVersionDifferential(f *testing.F) { + for _, v := range versionCorpus { + f.Add(v) + } + + f.Fuzz(func(t *testing.T, v string) { + got, gotErr := StrictNewVersion(v) + want, wantErr := refStrictNewVersion(v) + + if !sameErr(gotErr, wantErr) { + t.Fatalf("StrictNewVersion(%q): error %v, want %v", v, gotErr, wantErr) + } + if !sameVersion(got, want) { + t.Fatalf("StrictNewVersion(%q): %#v, want %#v", v, got, want) + } + }) +} + +func FuzzNewVersionDifferential(f *testing.F) { + for _, v := range versionCorpus { + f.Add(v) + } + + f.Fuzz(func(t *testing.T, v string) { + for _, coerce := range []bool{true, false} { + for _, detailed := range []bool{true, false} { + // The detailed setting only applies when versions are not + // coerced. + if coerce && !detailed { + continue + } + + CoerceNewVersion = coerce + DetailedNewVersionErrors = detailed + + got, gotErr := NewVersion(v) + want, wantErr := refNewVersion(v) + + if !sameErr(gotErr, wantErr) { + t.Fatalf("NewVersion(%q) with coerce=%t detailed=%t: error %v, want %v", + v, coerce, detailed, gotErr, wantErr) + } + if !sameVersion(got, want) { + t.Fatalf("NewVersion(%q) with coerce=%t detailed=%t: %#v, want %#v", + v, coerce, detailed, got, want) + } + } + } + + CoerceNewVersion = true + DetailedNewVersionErrors = true + }) +} diff --git a/version_test.go b/version_test.go index 5ebe9e0..c46e746 100644 --- a/version_test.go +++ b/version_test.go @@ -69,6 +69,23 @@ func TestStrictNewVersion(t *testing.T) { {"-invalid.01", true}, {"alpha+beta", true}, {"1.2.3-alpha_beta+foo", true}, + + // Dangling separators, whitespace, and other malformed input. + {"1.0.0-", true}, // An empty pre-release + {"1.0.0+", true}, // An empty metadata + {"1.0.0-alpha+", true}, // An empty metadata following a pre-release + {"1.0.0-alpha.", true}, // A trailing empty pre-release segment + {"1.0.0+meta.", true}, // A trailing empty metadata segment + {"1.0.0+meta..meta", true}, // An empty metadata segment + {"1.2.", true}, // A trailing empty number segment + {"1.", true}, // A trailing empty number segment + {" 1.2.3", true}, // Leading whitespace + {"1.2.3 ", true}, // Trailing whitespace + {"1.2 .3", true}, // Whitespace within the number segments + {"1.2.3-alpha 1", true}, // Whitespace within the pre-release + {"-1.2.3", true}, // A negative major version + {"1.2.3-rc.01", true}, // A leading 0 on a numeric pre-release segment + {"1.2.3-01.2", true}, // A leading 0 on a numeric pre-release segment {"1.0.0-alpha..1", true}, } @@ -138,6 +155,23 @@ func TestNewVersion(t *testing.T) { {"9.8.7+meta+meta", true}, // Multiple metadata parts {"1.2.31----RC-SNAPSHOT.12.09.1--.12+788", true}, // Leading 0 in a number part of a pre-release segment + // Dangling separators, whitespace, and other malformed input. + {"1.0.0-", true}, // An empty pre-release + {"1.0.0+", true}, // An empty metadata + {"1.0.0-alpha+", true}, // An empty metadata following a pre-release + {"1.0.0-alpha.", true}, // A trailing empty pre-release segment + {"1.0.0+meta.", true}, // A trailing empty metadata segment + {"1.0.0+meta..meta", true}, // An empty metadata segment + {"1.2.", true}, // A trailing empty number segment + {"1.", true}, // A trailing empty number segment + {" 1.2.3", true}, // Leading whitespace + {"1.2.3 ", true}, // Trailing whitespace + {"1.2 .3", true}, // Whitespace within the number segments + {"1.2.3-alpha 1", true}, // Whitespace within the pre-release + {"-1.2.3", true}, // A negative major version + {"1.2.3-rc.01", true}, // A leading 0 on a numeric pre-release segment + {"1.2.3-01.2", true}, // A leading 0 on a numeric pre-release segment + // Versions that are invalid but in loose mode are handled. // This enables a calver-ish style. This pattern has long // been supported by this package even though it technically @@ -218,6 +252,23 @@ func TestNewVersion(t *testing.T) { {"1.1.01", true}, // A leading 0 on a number segment {"9.8.7+meta+meta", true}, // Multiple metadata parts {"1.2.31----RC-SNAPSHOT.12.09.1--.12+788", true}, // Leading 0 in a number part of a pre-release segment + + // Dangling separators, whitespace, and other malformed input. + {"1.0.0-", true}, // An empty pre-release + {"1.0.0+", true}, // An empty metadata + {"1.0.0-alpha+", true}, // An empty metadata following a pre-release + {"1.0.0-alpha.", true}, // A trailing empty pre-release segment + {"1.0.0+meta.", true}, // A trailing empty metadata segment + {"1.0.0+meta..meta", true}, // An empty metadata segment + {"1.2.", true}, // A trailing empty number segment + {"1.", true}, // A trailing empty number segment + {" 1.2.3", true}, // Leading whitespace + {"1.2.3 ", true}, // Trailing whitespace + {"1.2 .3", true}, // Whitespace within the number segments + {"1.2.3-alpha 1", true}, // Whitespace within the pre-release + {"-1.2.3", true}, // A negative major version + {"1.2.3-rc.01", true}, // A leading 0 on a numeric pre-release segment + {"1.2.3-01.2", true}, // A leading 0 on a numeric pre-release segment } for _, tc := range tests {