diff --git a/cmd/mkversion/mkversion.go b/cmd/mkversion/mkversion.go new file mode 100644 index 000000000..c8c8bf179 --- /dev/null +++ b/cmd/mkversion/mkversion.go @@ -0,0 +1,44 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// mkversion gets version info from git and outputs a bunch of shell variables +// that get used elsewhere in the build system to embed version numbers into +// binaries. +package main + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "time" + + "tailscale.com/tailcfg" + "tailscale.com/version/mkversion" +) + +func main() { + prefix := "" + if len(os.Args) > 1 { + if os.Args[1] == "--export" { + prefix = "export " + } else { + fmt.Println("usage: mkversion [--export|-h|--help]") + os.Exit(1) + } + } + + var b bytes.Buffer + io.WriteString(&b, mkversion.Info().String()) + // Copyright and the client capability are not part of the version + // information, but similarly used in Xcode builds to embed in the metadata, + // thus generate them now. + copyright := fmt.Sprintf("Copyright © %d Tailscale Inc. All Rights Reserved.", time.Now().Year()) + fmt.Fprintf(&b, "VERSION_COPYRIGHT=%q\n", copyright) + fmt.Fprintf(&b, "VERSION_CAPABILITY=%d\n", tailcfg.CurrentCapabilityVersion) + s := bufio.NewScanner(&b) + for s.Scan() { + fmt.Println(prefix + s.Text()) + } +} diff --git a/version/mkversion/mkversion.go b/version/mkversion/mkversion.go new file mode 100644 index 000000000..6ca8ebd4c --- /dev/null +++ b/version/mkversion/mkversion.go @@ -0,0 +1,481 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// Package mkversion gets version info from git and provides a bunch of +// differently formatted version strings that get used elsewhere in the build +// system to embed version numbers into binaries. +package mkversion + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +type VersionInfo struct { + Short string + Long string + OSSHash string + CorpHash string + Xcode string // For embedding into Xcode metadata (iOS and macsys) + XcodeMacOS string // For embedding into Xcode metadata (macOS) + Winres string // For embedding into Windows metadata + OSSDate string // Unix timestamp of commit date (git: %ct) + CorpDate string // Unix timestamp of commit date (git: %ct) + Track string + MSIProductCodes map[string]string +} + +func (v VersionInfo) String() string { + f := fmt.Fprintf + var b bytes.Buffer + f(&b, "VERSION_SHORT=%q\n", v.Short) + f(&b, "VERSION_LONG=%q\n", v.Long) + f(&b, "VERSION_GIT_HASH=%q\n", v.OSSHash) + f(&b, "VERSION_EXTRA_HASH=%q\n", v.CorpHash) + f(&b, "VERSION_XCODE=%q\n", v.Xcode) + f(&b, "VERSION_XCODE_MACOS=%q\n", v.XcodeMacOS) + f(&b, "VERSION_WINRES=%q\n", v.Winres) + f(&b, "VERSION_TRACK=%q\n", v.Track) + + // Ensure a predictable order for these variables for testing purposes. + for _, k := range []string{"amd64", "arm64", "x86"} { + f(&b, "VERSION_MSIPRODUCT_%s=%q\n", strings.ToUpper(k), v.MSIProductCodes[k]) + } + + return b.String() +} + +// Info constructs a VersionInfo from the current working directory and returns +// it, or terminates the process via log.Fatal. +func Info() VersionInfo { + v, err := InfoFrom("") + if err != nil { + log.Fatal(err) + } + return v +} + +// InfoFrom constructs a VersionInfo from dir and returns it, or an error. +func InfoFrom(dir string) (VersionInfo, error) { + runner := dirRunner(dir) + + var v verInfo + var err error + v.corpHash, err = runner.output("git", "rev-parse", "HEAD") + if err != nil { + return VersionInfo{}, err + } + v.corpDate, err = runner.output("git", "log", "-n1", "--format=%ct", "HEAD") + if err != nil { + return VersionInfo{}, err + } + if !runner.ok("git", "diff-index", "--quiet", "HEAD") { + v.corpHash = v.corpHash + "-dirty" + } + + ossHash, ossDir, err := parseGoMod(runner) + if err != nil { + return VersionInfo{}, err + } + if ossHash != "" { + v.ossInfo, err = infoFromCache(ossHash, runner) + } else { + v.ossInfo, err = infoFromDir(ossDir) + } + if err != nil { + return VersionInfo{}, err + } + + return mkOutput(v) + +} + +func mkOutput(v verInfo) (VersionInfo, error) { + var changeSuffix string + if v.minor%2 == 1 { + // Odd minor numbers are unstable builds. + if v.patch != 0 { + return VersionInfo{}, fmt.Errorf("unstable release %d.%d.%d has a non-zero patch number, which is not allowed", v.major, v.minor, v.patch) + } + v.patch = v.changeCount + } else if v.changeCount != 0 { + // Even minor numbers are stable builds, but stable builds are + // supposed to have a zero change count. Therefore, we're currently + // describing a commit that's on a release branch, but hasn't been + // tagged as a patch release yet. + // + // We used to change the version number to 0.0.0 in that case, but that + // caused some features to get disabled due to the low version number. + // Instead, add yet another suffix to the version number, with a change + // count. + changeSuffix = "-" + strconv.Itoa(v.changeCount) + } + + var hashes string + if v.corpHash != "" { + hashes = "-g" + shortHash(v.corpHash) + } + if v.hash != "" { + hashes = "-t" + shortHash(v.hash) + hashes + } + + var track string + if v.minor%2 == 1 { + track = "unstable" + } else { + track = "stable" + } + + // Generate a monotonically increasing version number for the macOS app, as + // expected by Apple. We use the date so that it's always increasing (if we + // based it on the actual version number we'd run into issues when doing + // cherrypick stable builds from a release branch after unstable builds from + // HEAD). + corpSec, err := strconv.ParseInt(v.corpDate, 10, 64) + if err != nil { + return VersionInfo{}, fmt.Errorf("Culd not parse corpDate %q: %w", v.corpDate, err) + } + corpTime := time.Unix(corpSec, 0).UTC() + // We started to need to do this in 2023, and the last Apple-generated + // incrementing build number was 273. To avoid using up the space, we + // use as the major version (thus 273.*, 274.* in 2024, etc.), + // so that we we're still in the same range. This way if Apple goes back to + // auto-incrementing the number for us, we can go back to it with + // reasonable-looking numbers. + xcodeMacOS := fmt.Sprintf("%d.%d.%d", corpTime.Year()-1750, corpTime.YearDay(), corpTime.Hour()*60*60+corpTime.Minute()*60+corpTime.Second()) + + return VersionInfo{ + Short: fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch), + Long: fmt.Sprintf("%d.%d.%d%s%s", v.major, v.minor, v.patch, changeSuffix, hashes), + OSSHash: fmt.Sprintf("%s", v.hash), + OSSDate: fmt.Sprintf("%s", v.ossInfo.date), + CorpHash: fmt.Sprintf("%s", v.corpHash), + CorpDate: fmt.Sprintf("%s", v.corpDate), + Xcode: fmt.Sprintf("%d.%d.%d", v.major+100, v.minor, v.patch), + XcodeMacOS: xcodeMacOS, + Winres: fmt.Sprintf("%d,%d,%d,0", v.major, v.minor, v.patch), + Track: track, + MSIProductCodes: makeMSIProductCodes(v, track), + }, nil +} + +// makeMSIProductCodes produces per-architecture v5 UUIDs derived from the pkgs +// url that would be used for the current version, thus ensuring that product IDs +// are mapped 1:1 to a unique version number. +func makeMSIProductCodes(v verInfo, track string) map[string]string { + urlBase := fmt.Sprintf("https://pkgs.tailscale.com/%s/tailscale-setup-%d.%d.%d-", track, v.major, v.minor, v.patch) + + result := map[string]string{} + + for _, arch := range []string{"amd64", "arm64", "x86"} { + url := fmt.Sprintf("%s%s.msi", urlBase, arch) + curUUID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(url)) + // MSI prefers hex digits in UUIDs to be uppercase. + result[arch] = strings.ToUpper(curUUID.String()) + } + + return result +} + +func gitRootDir() (string, error) { + top, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + return "", fmt.Errorf("failed to find git top level (not in corp git?): %w", err) + } + return strings.TrimSpace(string(top)), nil +} + +func parseGoMod(runner dirRunner) (ossShortHash, localCheckout string, err error) { + goBin := filepath.Join(runtime.GOROOT(), "bin", "go"+exe()) + if !strings.HasPrefix(goBin, "/") { + // GOROOT got -trimpath'd, fall back to hoping $PATH has a + // working go. + goBin = "go" + } + mod, err := runner.output(goBin, "mod", "edit", "--json") + if err != nil { + return "", "", err + } + var mj modJSON + if err := json.Unmarshal([]byte(mod), &mj); err != nil { + return "", "", fmt.Errorf("parsing go.mod: %w", err) + } + + for _, r := range mj.Replace { + if r.Old.Path != "tailscale.com" { + continue + } + if filepath.IsAbs(r.New.Path) { + return "", r.New.Path, nil + } + gitRoot, err := gitRootDir() + if err != nil { + return "", "", err + } + return "", filepath.Join(gitRoot, r.New.Path), nil + } + for _, r := range mj.Require { + if r.Path != "tailscale.com" { + continue + } + shortHash := r.Version[strings.LastIndex(r.Version, "-")+1:] + return shortHash, "", nil + } + return "", "", fmt.Errorf("failed to find tailscale.com module in go.mod") +} + +func exe() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} + +type verInfo struct { + ossInfo + corpHash string + corpDate string +} + +// unknownPatchVersion is the patch version used when the oss package +// doesn't contain enough version information to derive the correct +// version. Such builds only get used when generating bug reports in +// an ephemeral working environment, so will never be distributed. As +// such, we use a highly visible sentinel patch number. +const unknownPatchVersion = 9999999 + +type ossInfo struct { + major, minor, patch int + changeCount int + hash string + date string +} + +func isBareRepo(r dirRunner) (bool, error) { + s, err := r.output("git", "rev-parse", "--is-bare-repository") + if err != nil { + return false, err + } + o := strings.TrimSpace(s) + return o == "true", nil +} + +func infoFromCache(shortHash string, runner dirRunner) (ossInfo, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + return ossInfo{}, fmt.Errorf("Getting user cache dir: %w", err) + } + ossCache := filepath.Join(cacheDir, "tailscale-oss") + r := dirRunner(ossCache) + + cloneRequired := false + if _, err := os.Stat(ossCache); err != nil { + cloneRequired = true + } else { + isBare, err := isBareRepo(r) + if err != nil { + return ossInfo{}, err + } + if isBare { + cloneRequired = true + if err := os.RemoveAll(ossCache); err != nil { + return ossInfo{}, fmt.Errorf("removing old cache dir failed: %w", err) + } + } + } + + if cloneRequired { + if !runner.ok("git", "clone", "https://github.com/tailscale/tailscale", ossCache) { + return ossInfo{}, fmt.Errorf("cloning OSS repo failed") + } + } + + if !r.ok("git", "cat-file", "-e", shortHash) { + if !r.ok("git", "fetch", "origin") { + return ossInfo{}, fmt.Errorf("updating OSS repo failed") + } + } + hash, err := r.output("git", "rev-parse", shortHash) + if err != nil { + return ossInfo{}, err + } + date, err := r.output("git", "log", "-n1", "--format=%ct", shortHash) + if err != nil { + return ossInfo{}, err + } + baseHash, err := r.output("git", "rev-list", "--max-count=1", hash, "--", "VERSION.txt") + if err != nil { + return ossInfo{}, err + } + s, err := r.output("git", "show", baseHash+":VERSION.txt") + if err != nil { + return ossInfo{}, err + } + major, minor, patch, err := parseVersion(s) + if err != nil { + return ossInfo{}, err + } + s, err = r.output("git", "rev-list", "--count", hash, "^"+baseHash) + if err != nil { + return ossInfo{}, err + } + changeCount, err := strconv.Atoi(s) + if err != nil { + return ossInfo{}, fmt.Errorf("infoFromCache: parsing changeCount %q: %w", changeCount, err) + } + + return ossInfo{ + major: major, + minor: minor, + patch: patch, + changeCount: changeCount, + hash: hash, + date: date, + }, nil +} + +func infoFromDir(dir string) (ossInfo, error) { + r := dirRunner(dir) + gitDir := filepath.Join(dir, ".git") + if _, err := os.Stat(gitDir); err != nil { + // Raw directory fetch, get as much info as we can and make up the rest. + s, err := readFile(filepath.Join(dir, "VERSION.txt")) + if err != nil { + return ossInfo{}, err + } + major, minor, patch, err := parseVersion(s) + return ossInfo{ + major: major, + minor: minor, + patch: patch, + changeCount: unknownPatchVersion, + }, err + } + + hash, err := r.output("git", "rev-parse", "HEAD") + if err != nil { + return ossInfo{}, err + } + date, err := r.output("git", "log", "-n1", "--format=%%ct", "HEAD") + if err != nil { + return ossInfo{}, err + } + baseHash, err := r.output("git", "rev-list", "--max-count=1", hash, "--", "VERSION.txt") + if err != nil { + return ossInfo{}, err + } + s, err := r.output("git", "show", baseHash+":VERSION.txt") + if err != nil { + return ossInfo{}, err + } + major, minor, patch, err := parseVersion(s) + if err != nil { + return ossInfo{}, err + } + s, err = r.output("git", "rev-list", "--count", hash, "^"+baseHash) + if err != nil { + return ossInfo{}, err + } + changeCount, err := strconv.Atoi(s) + if err != nil { + return ossInfo{}, err + } + + return ossInfo{ + major: major, + minor: minor, + patch: patch, + changeCount: changeCount, + hash: hash, + date: date, + }, nil +} + +type modJSON struct { + Require []goPath + Replace []modReplace +} + +type modReplace struct { + Old, New goPath +} + +type goPath struct { + Path string + Version string +} + +func parseVersion(s string) (major, minor, patch int, err error) { + fs := strings.Split(strings.TrimSpace(s), ".") + if len(fs) != 3 { + err = fmt.Errorf("parseVersion: parsing %q: wrong number of parts: %d", s, len(fs)) + return + } + ints := make([]int, 0, 3) + for _, s := range fs { + var i int + i, err = strconv.Atoi(s) + if err != nil { + err = fmt.Errorf("parseVersion: parsing %q: %w", s, err) + return + } + ints = append(ints, i) + } + return ints[0], ints[1], ints[2], nil +} + +func shortHash(hash string) string { + if len(hash) < 9 { + return hash + } + return hash[:9] +} + +func readFile(path string) (string, error) { + bs, err := ioutil.ReadFile(path) + return strings.TrimSpace(string(bs)), err +} + +// dirRunner executes commands in the specified dir. +type dirRunner string + +func (r dirRunner) output(prog string, args ...string) (string, error) { + cmd := exec.Command(prog, args...) + // Sometimes, our binaries end up running in a world where + // GO111MODULE=off, because x/tools/go/packages disables Go + // modules on occasion and then runs other Go code. This breaks + // executing "go mod edit", which requires that Go modules be + // enabled. + // + // Since nothing we do here ever wants Go modules to be turned + // off, force it on here so that we can read module data + // regardless of the environment. + cmd.Env = append(os.Environ(), "GO111MODULE=on") + cmd.Dir = string(r) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("running %v: %w, out=%s, err=%s", cmd.Args, err, out, ee.Stderr) + } + return "", fmt.Errorf("running %v: %w, %s", cmd.Args, err, out) + } + return strings.TrimSpace(string(out)), nil +} + +func (r dirRunner) ok(prog string, args ...string) bool { + cmd := exec.Command(prog, args...) + cmd.Dir = string(r) + return cmd.Run() == nil +} diff --git a/version/mkversion/mkversion_test.go b/version/mkversion/mkversion_test.go new file mode 100644 index 000000000..e1b43dab0 --- /dev/null +++ b/version/mkversion/mkversion_test.go @@ -0,0 +1,151 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package mkversion + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +func mkInfo(gitHash, otherHash, corpDate string, major, minor, patch, changeCount int) verInfo { + return verInfo{ + ossInfo: ossInfo{ + major: major, + minor: minor, + patch: patch, + changeCount: changeCount, + hash: gitHash, + }, + corpHash: otherHash, + corpDate: corpDate, + } +} + +func TestMkversion(t *testing.T) { + corpDate := fmt.Sprintf("%d", time.Date(2023, time.January, 27, 1, 2, 3, 4, time.UTC).Unix()) + + tests := []struct { + in verInfo + want string + }{ + {mkInfo("abcdef", "", corpDate, 0, 98, 0, 0), ` + VERSION_SHORT="0.98.0" + VERSION_LONG="0.98.0-tabcdef" + VERSION_GIT_HASH="abcdef" + VERSION_EXTRA_HASH="" + VERSION_XCODE="100.98.0" + VERSION_XCODE_MACOS="273.27.3723" + VERSION_WINRES="0,98,0,0" + VERSION_TRACK="stable" + VERSION_MSIPRODUCT_AMD64="C653B075-AD91-5265-9DF8-0087D35D148D" + VERSION_MSIPRODUCT_ARM64="1C41380B-A742-5A3C-AF5D-DF7894DD0FB8" + VERSION_MSIPRODUCT_X86="4ABDDA14-7499-5C2E-A62A-DD435C50C4CB"`}, + {mkInfo("abcdef", "", corpDate, 0, 98, 1, 0), ` + VERSION_SHORT="0.98.1" + VERSION_LONG="0.98.1-tabcdef" + VERSION_GIT_HASH="abcdef" + VERSION_EXTRA_HASH="" + VERSION_XCODE="100.98.1" + VERSION_XCODE_MACOS="273.27.3723" + VERSION_WINRES="0,98,1,0" + VERSION_TRACK="stable" + VERSION_MSIPRODUCT_AMD64="DFD6DCF2-06D8-5D19-BDA0-FAF31E44EC23" + VERSION_MSIPRODUCT_ARM64="A4CCF19C-372B-5007-AFD8-1AF661DFF670" + VERSION_MSIPRODUCT_X86="FF12E937-DDC4-5868-9B63-D35B2050D4EA"`}, + {mkInfo("abcdef", "", corpDate, 1, 2, 9, 0), ` + VERSION_SHORT="1.2.9" + VERSION_LONG="1.2.9-tabcdef" + VERSION_GIT_HASH="abcdef" + VERSION_EXTRA_HASH="" + VERSION_XCODE="101.2.9" + VERSION_XCODE_MACOS="273.27.3723" + VERSION_WINRES="1,2,9,0" + VERSION_TRACK="stable" + VERSION_MSIPRODUCT_AMD64="D47B5157-FF26-5A10-A94E-50E4529303A9" + VERSION_MSIPRODUCT_ARM64="91D16F75-2A12-5E12-820A-67B89BF858E7" + VERSION_MSIPRODUCT_X86="8F1AC1C6-B93B-5C70-802E-6AE9591FA0D6"`}, + {mkInfo("abcdef", "", corpDate, 1, 15, 0, 129), ` + VERSION_SHORT="1.15.129" + VERSION_LONG="1.15.129-tabcdef" + VERSION_GIT_HASH="abcdef" + VERSION_EXTRA_HASH="" + VERSION_XCODE="101.15.129" + VERSION_XCODE_MACOS="273.27.3723" + VERSION_WINRES="1,15,129,0" + VERSION_TRACK="unstable" + VERSION_MSIPRODUCT_AMD64="89C96952-1FB8-5A4D-B02E-16A8060C56AA" + VERSION_MSIPRODUCT_ARM64="DB1A2E86-66C4-5CEC-8F4C-7DB805370F3A" + VERSION_MSIPRODUCT_X86="DC57C0C3-5164-5C92-86B3-2800CEFF0540"`}, + {mkInfo("abcdef", "", corpDate, 1, 2, 0, 17), ` + VERSION_SHORT="1.2.0" + VERSION_LONG="1.2.0-17-tabcdef" + VERSION_GIT_HASH="abcdef" + VERSION_EXTRA_HASH="" + VERSION_XCODE="101.2.0" + VERSION_XCODE_MACOS="273.27.3723" + VERSION_WINRES="1,2,0,0" + VERSION_TRACK="stable" + VERSION_MSIPRODUCT_AMD64="0F9709AE-0E5E-51AF-BCCD-A25314B4CE8B" + VERSION_MSIPRODUCT_ARM64="39D5D46E-E644-5C80-9EF8-224AC1AD5969" + VERSION_MSIPRODUCT_X86="4487136B-2D11-5E42-BD80-B8529F3326F4"`}, + {mkInfo("abcdef", "defghi", corpDate, 1, 15, 0, 129), ` + VERSION_SHORT="1.15.129" + VERSION_LONG="1.15.129-tabcdef-gdefghi" + VERSION_GIT_HASH="abcdef" + VERSION_EXTRA_HASH="defghi" + VERSION_XCODE="101.15.129" + VERSION_XCODE_MACOS="273.27.3723" + VERSION_WINRES="1,15,129,0" + VERSION_TRACK="unstable" + VERSION_MSIPRODUCT_AMD64="89C96952-1FB8-5A4D-B02E-16A8060C56AA" + VERSION_MSIPRODUCT_ARM64="DB1A2E86-66C4-5CEC-8F4C-7DB805370F3A" + VERSION_MSIPRODUCT_X86="DC57C0C3-5164-5C92-86B3-2800CEFF0540"`}, + {mkInfo("abcdef", "", corpDate, 1, 2, 0, 17), ` + VERSION_SHORT="1.2.0" + VERSION_LONG="1.2.0-17-tabcdef" + VERSION_GIT_HASH="abcdef" + VERSION_EXTRA_HASH="" + VERSION_XCODE="101.2.0" + VERSION_XCODE_MACOS="273.27.3723" + VERSION_WINRES="1,2,0,0" + VERSION_TRACK="stable" + VERSION_MSIPRODUCT_AMD64="0F9709AE-0E5E-51AF-BCCD-A25314B4CE8B" + VERSION_MSIPRODUCT_ARM64="39D5D46E-E644-5C80-9EF8-224AC1AD5969" + VERSION_MSIPRODUCT_X86="4487136B-2D11-5E42-BD80-B8529F3326F4"`}, + {mkInfo("abcdef", "defghi", corpDate, 1, 15, 0, 129), ` + VERSION_SHORT="1.15.129" + VERSION_LONG="1.15.129-tabcdef-gdefghi" + VERSION_GIT_HASH="abcdef" + VERSION_EXTRA_HASH="defghi" + VERSION_XCODE="101.15.129" + VERSION_XCODE_MACOS="273.27.3723" + VERSION_WINRES="1,15,129,0" + VERSION_TRACK="unstable" + VERSION_MSIPRODUCT_AMD64="89C96952-1FB8-5A4D-B02E-16A8060C56AA" + VERSION_MSIPRODUCT_ARM64="DB1A2E86-66C4-5CEC-8F4C-7DB805370F3A" + VERSION_MSIPRODUCT_X86="DC57C0C3-5164-5C92-86B3-2800CEFF0540"`}, + {mkInfo("abcdef", "", corpDate, 0, 99, 5, 0), ""}, // unstable, patch number not allowed + {mkInfo("abcdef", "", corpDate, 0, 99, 5, 123), ""}, // unstable, patch number not allowed + {mkInfo("abcdef", "defghi", "", 1, 15, 0, 129), ""}, // missing corpDate + } + + for _, test := range tests { + want := strings.ReplaceAll(strings.TrimSpace(test.want), " ", "") + info, err := mkOutput(test.in) + if err != nil { + if test.want != "" { + t.Errorf("%#v got unexpected error %v", test.in, err) + } + continue + } + got := strings.TrimSpace(info.String()) + if diff := cmp.Diff(got, want); want != "" && diff != "" { + t.Errorf("%#v wrong output (-got+want):\n%s", test.in, diff) + } + } +}