diff --git a/version/cmp.go b/version/cmp.go new file mode 100644 index 000000000..04591854c --- /dev/null +++ b/version/cmp.go @@ -0,0 +1,142 @@ +// Copyright (c) 2020 Tailscale Inc & 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 ( + "log" + "strconv" + "strings" +) + +// AtLeast returns whether version is at least the specified minimum +// version. +// +// Version comparison in Tailscale is a little complex, because we +// switched "styles" a few times, and additionally have a completely +// separate track of version numbers for OSS-only builds. +// +// AtLeast acts conservatively, returning true only if it's certain +// that version is at least minimum. As a result, it can produce false +// negatives, for example when an OSS build supports a given feature, +// but AtLeast is called with an official release number as the +// minimum +// +// version and minimum can both be either an official Tailscale +// version numbers (major.minor.patch-extracommits-extrastring), or an +// OSS build datestamp (date.YYYYMMDD). For Tailscale version numbers, +// AtLeast also accepts a prefix of a full version, in which case all +// missing fields are assumed to be zero. +func AtLeast(version string, minimum string) bool { + v, ok := parse(version) + if !ok { + return false + } + m, ok := parse(minimum) + if !ok { + return false + } + + log.Print(v, m) + switch { + case v.Datestamp != 0 && m.Datestamp == 0: + // OSS version vs. Tailscale version + return false + case v.Datestamp == 0 && m.Datestamp != 0: + // Tailscale version vs. OSS version + return false + case v.Datestamp != 0: + // OSS version vs. OSS version + return v.Datestamp >= m.Datestamp + case v.Major == m.Major && v.Minor == m.Minor && v.Patch == m.Patch && v.ExtraCommits == m.ExtraCommits: + // Exactly equal Tailscale versions + return true + case v.Major != m.Major: + return v.Major > m.Major + case v.Minor != m.Minor: + return v.Minor > m.Minor + case v.Patch != m.Patch: + return v.Patch > m.Patch + default: + return v.ExtraCommits > m.ExtraCommits + } +} + +type parsed struct { + Major, Minor, Patch, ExtraCommits int // for Tailscale version e.g. e.g. "0.99.1-20" + Datestamp int // for OSS version e.g. "date.20200612" +} + +func parse(version string) (parsed, bool) { + if strings.HasPrefix(version, "date.") { + stamp, err := strconv.Atoi(version[5:]) + if err != nil { + return parsed{}, false + } + return parsed{Datestamp: stamp}, true + } + + var ret parsed + + major, rest, err := splitNumericPrefix(version) + if err != nil { + return parsed{}, false + } + ret.Major = major + if len(rest) == 0 { + return ret, true + } + + ret.Minor, rest, err = splitNumericPrefix(rest[1:]) + if err != nil { + return parsed{}, false + } + if len(rest) == 0 { + return ret, true + } + + // Optional patch version, if the next separator is a dot. + if rest[0] == '.' { + ret.Patch, rest, err = splitNumericPrefix(rest[1:]) + if err != nil { + return parsed{}, false + } + if len(rest) == 0 { + return ret, true + } + } + + // Optional extraCommits, if the next bit can be completely + // consumed as an integer. + if rest[0] != '-' { + return parsed{}, false + } + + var trailer string + ret.ExtraCommits, trailer, err = splitNumericPrefix(rest[1:]) + if err != nil || (len(trailer) > 0 && trailer[0] != '-') { + // rest was probably the string trailer, ignore it. + ret.ExtraCommits = 0 + } + return ret, true +} + +func splitNumericPrefix(s string) (int, string, error) { + for i, r := range s { + if r >= '0' && r <= '9' { + continue + } + ret, err := strconv.Atoi(s[:i]) + if err != nil { + return 0, "", err + } + return ret, s[i:], nil + } + + ret, err := strconv.Atoi(s) + if err != nil { + return 0, "", err + } + return ret, "", nil +} diff --git a/version/cmp_test.go b/version/cmp_test.go new file mode 100644 index 000000000..3124a579b --- /dev/null +++ b/version/cmp_test.go @@ -0,0 +1,71 @@ +// Copyright (c) 2020 Tailscale Inc & 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 ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestParse(t *testing.T) { + tests := []struct { + version string + parsed parsed + want bool + }{ + {"1", parsed{Major: 1}, true}, + {"1.2", parsed{Major: 1, Minor: 2}, true}, + {"1.2.3", parsed{Major: 1, Minor: 2, Patch: 3}, true}, + {"1.2.3-4", parsed{Major: 1, Minor: 2, Patch: 3, ExtraCommits: 4}, true}, + {"1.2-4", parsed{Major: 1, Minor: 2, ExtraCommits: 4}, true}, + {"1.2.3-4-extra", parsed{Major: 1, Minor: 2, Patch: 3, ExtraCommits: 4}, true}, + {"1.2.3-4a-test", parsed{Major: 1, Minor: 2, Patch: 3}, true}, + {"1.2-extra", parsed{Major: 1, Minor: 2}, true}, + {"1.2.3-extra", parsed{Major: 1, Minor: 2, Patch: 3}, true}, + {"date.20200612", parsed{Datestamp: 20200612}, true}, + {"borkbork", parsed{}, false}, + {"1a.2.3", parsed{}, false}, + } + + for _, test := range tests { + gotParsed, got := parse(test.version) + if got != test.want { + t.Errorf("version(%q) = %v, want %v", test.version, got, test.want) + } + if diff := cmp.Diff(gotParsed, test.parsed); diff != "" { + t.Errorf("parse(%q) diff (-got+want):\n%s", test.version, diff) + } + } +} + +func TestAtLeast(t *testing.T) { + tests := []struct { + v, m string + want bool + }{ + {"1", "1", true}, + {"1.2", "1", true}, + {"1.2.3", "1", true}, + {"1.2.3-4", "1", true}, + {"0.98-0", "0.98", true}, + {"0.97.1-216", "0.98", false}, + {"0.94", "0.98", false}, + {"0.98", "0.98", true}, + {"0.98.0-0", "0.98", true}, + {"1.2.3-4", "1.2.4-4", false}, + {"1.2.3-4", "1.2.3-4", true}, + {"date.20200612", "date.20200612", true}, + {"date.20200701", "date.20200612", true}, + {"date.20200501", "date.20200612", false}, + } + + for _, test := range tests { + got := AtLeast(test.v, test.m) + if got != test.want { + t.Errorf("AtLeast(%q, %q) = %v, want %v", test.v, test.m, got, test.want) + } + } +}