maintner/maintnerd/maintapi: factor out version parsing code

Create a new ./maintner/maintnerd/maintapi/version package and move
the version parsing code there. Change the implementation to use
strings.Split on "." rather than a regexp. Make it more precise at
rejecting invalid names and increase test coverage of edge cases.

Having this code be in a separate package makes it easier to use it
elsewhere when needed.

Change commit hash in TestSupportedGoReleases for -security branches,
to make the test failure more prominent.

Remove an unneeded blank line at the end of a function body.

Change-Id: I3168b0b25a6b6433d17f83b01580f08208532daf
Reviewed-on: https://go-review.googlesource.com/c/157559
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/maintner/maintnerd/maintapi/api.go b/maintner/maintnerd/maintapi/api.go
index f8ccd4e..62b52ee 100644
--- a/maintner/maintnerd/maintapi/api.go
+++ b/maintner/maintnerd/maintapi/api.go
@@ -8,12 +8,8 @@
 import (
 	"context"
 	"errors"
-	"fmt"
-	"io"
 	"log"
-	"regexp"
 	"sort"
-	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -21,6 +17,7 @@
 	"golang.org/x/build/gerrit"
 	"golang.org/x/build/maintner"
 	"golang.org/x/build/maintner/maintnerd/apipb"
+	"golang.org/x/build/maintner/maintnerd/maintapi/version"
 )
 
 // NewAPIService creates a gRPC Server that serves the Maintner API for the given corpus.
@@ -221,6 +218,31 @@
 	return res, nil
 }
 
+// parseTagVersion parses the major-minor-patch version triplet
+// from goX, goX.Y, or goX.Y.Z tag names,
+// and reports whether the tag name is valid.
+//
+// Tags with suffixes like "go1.2beta3" or "go1.2rc1" are rejected.
+//
+// For example, "go1" is parsed as version 1.0.0,
+// "go1.2" is parsed as version 1.2.0,
+// and "go1.2.3" is parsed as version 1.2.3.
+func parseTagVersion(tagName string) (major, minor, patch int32, ok bool) {
+	maj, min, pat, ok := version.ParseTag(tagName)
+	return int32(maj), int32(min), int32(pat), ok
+}
+
+// parseReleaseBranchVersion parses the major-minor version pair
+// from release-branch.goX or release-branch.goX.Y release branch names,
+// and reports whether the release branch name is valid.
+//
+// For example, "release-branch.go1" is parsed as version 1.0,
+// and "release-branch.go1.2" is parsed as version 1.2.
+func parseReleaseBranchVersion(branchName string) (major, minor int32, ok bool) {
+	maj, min, ok := version.ParseReleaseBranch(branchName)
+	return int32(maj), int32(min), ok
+}
+
 // ListGoReleases lists Go releases. A release is considered to exist
 // if a tag for it exists.
 func (s apiService) ListGoReleases(ctx context.Context, req *apipb.ListGoReleasesRequest) (*apipb.ListGoReleasesResponse, error) {
@@ -246,10 +268,6 @@
 	ForeachNonChangeRef(fn func(ref string, hash maintner.GitHash) error) error
 }
 
-// releaseBranchRx matches things of the form "release-branch.go1.5" or "release-branch.go2",
-// but not things like "release-branch.go1.10-security".
-var releaseBranchRx = regexp.MustCompile(`^release-branch\.go(\d{1,2})(?:\.(\d{1,3}))?$`)
-
 // supportedGoReleases returns the latest patches of releases
 // that are considered supported per policy.
 func supportedGoReleases(goProj nonChangeRefLister) ([]*apipb.GoRelease, error) {
@@ -275,11 +293,8 @@
 		case strings.HasPrefix(ref, "refs/tags/go"):
 			// Tag.
 			tagName := ref[len("refs/tags/"):]
-			var major, minor, patch int32
-			_, err := fmt.Sscanf(tagName, "go%d.%d.%d", &major, &minor, &patch)
-			if err == io.ErrUnexpectedEOF {
-				// Do nothing.
-			} else if err != nil {
+			major, minor, patch, ok := parseTagVersion(tagName)
+			if !ok {
 				return nil
 			}
 			if t, ok := tags[majorMinor{major, minor}]; ok && patch <= t.Patch {
@@ -295,16 +310,11 @@
 		case strings.HasPrefix(ref, "refs/heads/release-branch.go"):
 			// Release branch.
 			branchName := ref[len("refs/heads/"):]
-			m := releaseBranchRx.FindStringSubmatch(branchName)
-			if m == nil {
+			major, minor, ok := parseReleaseBranchVersion(branchName)
+			if !ok {
 				return nil
 			}
-			var major, minor int
-			major, _ = strconv.Atoi(m[1])
-			if len(m) > 2 {
-				minor, _ = strconv.Atoi(m[2])
-			}
-			branches[majorMinor{int32(major), int32(minor)}] = branch{
+			branches[majorMinor{major, minor}] = branch{
 				Name:   branchName,
 				Commit: hash,
 			}
diff --git a/maintner/maintnerd/maintapi/api_test.go b/maintner/maintnerd/maintapi/api_test.go
index 435700a..53c9335 100644
--- a/maintner/maintnerd/maintapi/api_test.go
+++ b/maintner/maintnerd/maintapi/api_test.go
@@ -115,7 +115,6 @@
 		}
 		*cl = old // restore
 	}
-
 }
 
 func TestTryWorkItem(t *testing.T) {
@@ -185,8 +184,8 @@
 					{"refs/heads/release-branch.go1.1", gitHash("1d6d8fca241bb611af51e265c1b5a2e9ae904702")},
 					{"refs/heads/release-branch.go1.10", gitHash("e97b7d68f107ff60152f5bd5701e0286f221ee93")},
 					{"refs/heads/release-branch.go1.11", gitHash("97781d2ed116d2cd9cb870d0b84fc0ec598c9abc")},
-					{"refs/heads/release-branch.go1.10-security", gitHash("e97b7d68f107ff60152f5bd5701e0286f221ee93")},
-					{"refs/heads/release-branch.go1.11-security", gitHash("97781d2ed116d2cd9cb870d0b84fc0ec598c9abc")},
+					{"refs/heads/release-branch.go1.10-security", gitHash("25ca8f49c3fc4a68daff7a23ab613e3453be5cda")},
+					{"refs/heads/release-branch.go1.11-security", gitHash("90c896448691b5edb0ab11110f37234f63cd28ed")},
 					{"refs/heads/release-branch.go1.2", gitHash("43d00b0942c1c6f43993ac71e1eea48e62e22b8d")},
 					{"refs/heads/release-branch.r59", gitHash("5d9765785dff74784bbdad43f7847b6825509032")},
 					{"refs/heads/release-branch.r60", gitHash("394b383a1ee0ac3fec5e453a7dbe590d3ce6d6b0")},
diff --git a/maintner/maintnerd/maintapi/version/version.go b/maintner/maintnerd/maintapi/version/version.go
new file mode 100644
index 0000000..f71f02e
--- /dev/null
+++ b/maintner/maintnerd/maintapi/version/version.go
@@ -0,0 +1,112 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package version implements logic to parse version
+// of Go tags and release branches.
+package version
+
+import (
+	"strings"
+)
+
+// ParseTag parses the major-minor-patch version triplet
+// from goX, goX.Y, or goX.Y.Z tag names,
+// and reports whether the tag name is valid.
+//
+// Tags with suffixes like "go1.2beta3" or "go1.2rc1"
+// are currently not supported, and get rejected.
+//
+// For example, "go1" is parsed as version 1.0.0,
+// "go1.2" is parsed as version 1.2.0,
+// and "go1.2.3" is parsed as version 1.2.3.
+func ParseTag(tagName string) (major, minor, patch int, ok bool) {
+	const prefix = "go"
+	if !strings.HasPrefix(tagName, prefix) {
+		return 0, 0, 0, false
+	}
+	if strings.HasSuffix(tagName, ".0") {
+		// Trailing zero version components must be omitted in Go tags,
+		// so reject if we see one.
+		return 0, 0, 0, false
+	}
+	v := strings.SplitN(tagName[len(prefix):], ".", 4)
+	if len(v) > 3 {
+		return 0, 0, 0, false
+	}
+	major, ok = parse0To999(v[0])
+	if !ok || major == 0 {
+		return 0, 0, 0, false
+	}
+	if len(v) == 2 || len(v) == 3 {
+		minor, ok = parse0To999(v[1])
+		if !ok {
+			return 0, 0, 0, false
+		}
+	}
+	if len(v) == 3 {
+		patch, ok = parse0To999(v[2])
+		if !ok {
+			return 0, 0, 0, false
+		}
+	}
+	return major, minor, patch, true
+}
+
+// ParseReleaseBranch parses the major-minor version pair
+// from release-branch.goX or release-branch.goX.Y release branch names,
+// and reports whether the release branch name is valid.
+//
+// For example, "release-branch.go1" is parsed as version 1.0,
+// and "release-branch.go1.2" is parsed as version 1.2.
+func ParseReleaseBranch(branchName string) (major, minor int, ok bool) {
+	const prefix = "release-branch.go"
+	if !strings.HasPrefix(branchName, prefix) {
+		return 0, 0, false
+	}
+	if strings.HasSuffix(branchName, ".0") {
+		// Trailing zero version components must be omitted in Go release branches,
+		// so reject if we see one.
+		return 0, 0, false
+	}
+	v := strings.SplitN(branchName[len(prefix):], ".", 3)
+	if len(v) > 2 {
+		return 0, 0, false
+	}
+	major, ok = parse0To999(v[0])
+	if !ok || major == 0 {
+		return 0, 0, false
+	}
+	if len(v) == 2 {
+		minor, ok = parse0To999(v[1])
+		if !ok {
+			return 0, 0, false
+		}
+	}
+	return major, minor, true
+}
+
+// parse0To999 converts the canonical ASCII string representation
+// of a number in the range [0, 999] to its integer form.
+// strconv.Itoa(n) will equal to s if and only if ok is true.
+//
+// It's similar to strconv.Atoi, except it doesn't permit
+// negative numbers, leading '+'/'-' signs, leading zeros,
+// or other potential valid but non-canonical string
+// representations of numbers.
+func parse0To999(s string) (n int, ok bool) {
+	if len(s) < 1 || 3 < len(s) {
+		return 0, false
+	}
+	if len(s) > 1 && s[0] == '0' {
+		// Leading zeros are rejected.
+		return 0, false
+	}
+	for _, c := range []byte(s) {
+		if c < '0' || '9' < c {
+			return 0, false
+		}
+		n = n*10 + int(c-'0')
+	}
+	return n, true
+}
diff --git a/maintner/maintnerd/maintapi/version/version_test.go b/maintner/maintnerd/maintapi/version/version_test.go
new file mode 100644
index 0000000..b4d0357
--- /dev/null
+++ b/maintner/maintnerd/maintapi/version/version_test.go
@@ -0,0 +1,160 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package version
+
+import (
+	"strconv"
+	"testing"
+	"testing/quick"
+)
+
+func TestParseTag(t *testing.T) {
+	tests := []struct {
+		tagName                         string
+		wantMajor, wantMinor, wantPatch int
+		wantOK                          bool
+	}{
+		{"go1", 1, 0, 0, true},
+		{"go1.2", 1, 2, 0, true},
+		{"go1.2.3", 1, 2, 3, true},
+		{"go23.45.67", 23, 45, 67, true},
+		{"not-go", 0, 0, 0, false},
+		{"go", 0, 0, 0, false},
+		{"go.", 0, 0, 0, false},
+		{"go1.", 0, 0, 0, false},
+		{"go1-bad", 0, 0, 0, false},
+		{"go1.2.", 0, 0, 0, false},
+		{"go1.2-bad", 0, 0, 0, false},
+		{"go1.2.3.", 0, 0, 0, false},
+		{"go1.2.3-bad", 0, 0, 0, false},
+		{"go1.2.3.4", 0, 0, 0, false},
+		{"go0.0.0", 0, 0, 0, false},
+		{"go-1", 0, 0, 0, false},
+		{"go1.-2", 0, 0, 0, false},
+		{"go1.2.-3", 0, 0, 0, false},
+		{"go+1", 0, 0, 0, false},
+		{"go01", 0, 0, 0, false},
+		{"go001", 0, 0, 0, false},
+		{"go1000", 0, 0, 0, false},
+		{"go1.0", 0, 0, 0, false},
+		{"go1.0.0", 0, 0, 0, false},
+		{"go1.2.0", 0, 0, 0, false},
+		{"go00", 0, 0, 0, false},
+		{"go00.2", 0, 0, 0, false},
+		{"go00.2.3", 0, 0, 0, false},
+		{"go1.00", 0, 0, 0, false},
+		{"go1.00.0", 0, 0, 0, false},
+		{"go1.00.3", 0, 0, 0, false},
+		{"go1.2.00", 0, 0, 0, false},
+	}
+	for i, tt := range tests {
+		major, minor, patch, ok := ParseTag(tt.tagName)
+		if got, want := ok, tt.wantOK; got != want {
+			t.Errorf("#%d %q: got ok = %v; want %v", i, tt.tagName, got, want)
+			continue
+		}
+		if !tt.wantOK {
+			continue
+		}
+		if got, want := major, tt.wantMajor; got != want {
+			t.Errorf("#%d %q: got major = %d; want %d", i, tt.tagName, got, want)
+		}
+		if got, want := minor, tt.wantMinor; got != want {
+			t.Errorf("#%d %q: got minor = %d; want %d", i, tt.tagName, got, want)
+		}
+		if got, want := patch, tt.wantPatch; got != want {
+			t.Errorf("#%d %q: got patch = %d; want %d", i, tt.tagName, got, want)
+		}
+	}
+}
+
+func TestParseReleaseBranch(t *testing.T) {
+	tests := []struct {
+		branchName           string
+		wantMajor, wantMinor int
+		wantOK               bool
+	}{
+		{"release-branch.go1", 1, 0, true},
+		{"release-branch.go1.2", 1, 2, true},
+		{"release-branch.go23.45", 23, 45, true},
+		{"not-release-branch", 0, 0, false},
+		{"release-branch.go", 0, 0, false},
+		{"release-branch.go.", 0, 0, false},
+		{"release-branch.go1.", 0, 0, false},
+		{"release-branch.go1-bad", 0, 0, false},
+		{"release-branch.go1.2.", 0, 0, false},
+		{"release-branch.go1.2-bad", 0, 0, false},
+		{"release-branch.go1.2.3", 0, 0, false},
+		{"release-branch.go0.0", 0, 0, false},
+		{"release-branch.go-1", 0, 0, false},
+		{"release-branch.go1.-2", 0, 0, false},
+		{"release-branch.go+1", 0, 0, false},
+		{"release-branch.go01", 0, 0, false},
+		{"release-branch.go001", 0, 0, false},
+		{"release-branch.go1000", 0, 0, false},
+		{"release-branch.go1.0", 0, 0, false},
+		{"release-branch.go00", 0, 0, false},
+		{"release-branch.go00.2", 0, 0, false},
+		{"release-branch.go1.00", 0, 0, false},
+	}
+	for i, tt := range tests {
+		major, minor, ok := ParseReleaseBranch(tt.branchName)
+		if got, want := ok, tt.wantOK; got != want {
+			t.Errorf("#%d %q: got ok = %v; want %v", i, tt.branchName, got, want)
+			continue
+		}
+		if !tt.wantOK {
+			continue
+		}
+		if got, want := major, tt.wantMajor; got != want {
+			t.Errorf("#%d %q: got major = %d; want %d", i, tt.branchName, got, want)
+		}
+		if got, want := minor, tt.wantMinor; got != want {
+			t.Errorf("#%d %q: got minor = %d; want %d", i, tt.branchName, got, want)
+		}
+	}
+}
+
+func TestParse0To999(t *testing.T) {
+	// The only accepted inputs are numbers in [0, 999] range
+	// in canonical string form. All other input should be rejected.
+	// Build a complete map of inputs to answers.
+	var golden = make(map[string]int) // input -> output
+	for n := 0; n <= 999; n++ {
+		golden[strconv.Itoa(n)] = n
+	}
+
+	// Numbers in [0, 999] range should be accepted.
+	for in, want := range golden {
+		got, ok := parse0To999(in)
+		if !ok {
+			t.Errorf("parse0To999(%q): got ok = false; want true", in)
+			continue
+		}
+		if got != want {
+			t.Errorf("parse0To999(%q): got n = %d; want %d", in, got, want)
+		}
+	}
+
+	// All other numbers should be rejected.
+	ints := func(x int) bool {
+		gotN, gotOK := parse0To999(strconv.Itoa(x))
+		wantN, wantOK := golden[strconv.Itoa(x)]
+		return gotOK == wantOK && gotN == wantN
+	}
+	if err := quick.Check(ints, nil); err != nil {
+		t.Error(err)
+	}
+
+	// All other strings should be rejected.
+	strings := func(x string) bool {
+		gotN, gotOK := parse0To999(x)
+		wantN, wantOK := golden[x]
+		return gotOK == wantOK && gotN == wantN
+	}
+	if err := quick.Check(strings, nil); err != nil {
+		t.Error(err)
+	}
+}