diff --git a/builder/build.go b/builder/build.go index adf8247215..4014a4d333 100644 --- a/builder/build.go +++ b/builder/build.go @@ -25,6 +25,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/gofrs/flock" "github.com/tinygo-org/tinygo/compileopts" @@ -34,6 +35,8 @@ import ( "github.com/tinygo-org/tinygo/loader" "github.com/tinygo-org/tinygo/stacksize" "github.com/tinygo-org/tinygo/transform" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" "tinygo.org/x/go-llvm" ) @@ -250,6 +253,26 @@ func Build(pkgName, outpath, tmpdir string, config *compileopts.Config) (BuildRe return result, err } + // Embed module build information so runtime/debug.ReadBuildInfo() works, + // mirroring what the standard `go build` toolchain does. We fill + // runtime/debug.modinfo (a plain string global) from the module info that + // `go list` already reported for the loaded packages; runtime/debug parses + // it back into a *BuildInfo. An explicit -ldflags="-X runtime/debug.modinfo=..." + // takes precedence. This is skipped in GOPATH mode (no module info) and when + // the main package isn't in a module. + if _, overridden := globalValues["runtime/debug"]["modinfo"]; !overridden { + mi, err := moduleBuildInfo(lprogram, config.Options.BuildVCS) + if err != nil { + return result, err + } + if mi != "" { + if globalValues["runtime/debug"] == nil { + globalValues["runtime/debug"] = map[string]string{} + } + globalValues["runtime/debug"]["modinfo"] = mi + } + } + // Store which filesystem paths map to which package name. result.PackagePathMap = make(map[string]string, len(lprogram.Packages)) for _, pkg := range lprogram.Sorted() { @@ -1612,3 +1635,248 @@ func b2u8(b bool) uint8 { } return 0 } + +// moduleBuildInfo constructs the module build-info string embedded into the +// runtime/debug.modinfo global, in the same textual format that +// runtime/debug.BuildInfo.String() produces (minus the leading "go" line, which +// runtime/debug supplies from runtime.Version()). runtime/debug.ReadBuildInfo +// parses it back into a *BuildInfo, so `go build`-style version reporting works +// under TinyGo without -ldflags. It returns "" when there is no module +// information to embed (e.g. GOPATH mode, or a main package outside any module). +// +// The layout is the reverse of runtime/debug.ParseBuildInfo: +// +// path\t
\n +// mod\t
\t\t\n +// dep\t\t\t\n (one per contributing module, sorted) +// +// The main module version is reported as "(devel)" for a local checkout, as the +// go toolchain does; VCS-derived pseudo-version stamping is a separate follow-up. +// buildVCS selects whether the vcs.* settings and a VCS-derived version are +// stamped in: "false" skips it, "true" and "auto" (and "", the zero value, for +// callers that don't set it) stamp when a usable repository is found. This +// mirrors the go toolchain's -buildvcs, including its reason for existing: +// stamping shells out to git on every build, which a caller may not want to +// pay for. +func moduleBuildInfo(lprogram *loader.Program, buildVCS string) (string, error) { + main := lprogram.MainPkg() + if main == nil || main.Module.Path == "" { + return "", nil // GOPATH mode or no module: nothing to embed. + } + + // Collect the distinct non-main modules that contributed packages to the + // build. As in the go toolchain, a module is listed if any of its packages + // are part of the build graph. + depVersions := make(map[string]string) // module path -> version + for _, pkg := range lprogram.Sorted() { + m := pkg.Module + if m.Path == "" || m.Main || m.Path == main.Module.Path { + continue + } + depVersions[m.Path] = m.Version + } + deps := make([]string, 0, len(depVersions)) + for path := range depVersions { + deps = append(deps, path) + } + sort.Strings(deps) + + // Derive the main module version. `go list` leaves it empty for a local + // checkout, so fall back to VCS stamping (as `go build` does): an exact tag + // on HEAD, otherwise a pseudo-version. This also yields the vcs.* build + // settings appended below. If VCS info isn't available, use "(devel)". + mainVersion := main.Module.Version + var vcsSettings string + if mainVersion == "" { + if buildVCS == "false" { + // Don't touch the repository at all: no git subprocesses run. + mainVersion = "(devel)" + } else { + v, s, gitErr := gitVCSStamp(main.Module.Dir, main.Module.Path) + switch { + case v != "": + mainVersion, vcsSettings = v, s + case s != "": + // A repository with no commits gives settings but no version. + mainVersion, vcsSettings = "(devel)", s + case buildVCS == "true": + // -buildvcs=true means the stamp was demanded, so failing to + // produce one is an error rather than a silent fallback. This + // matches the go toolchain. + reason := "not a git work tree, the module is not at the repository root, or git is unavailable" + if gitErr != nil { + reason = gitErr.Error() + } + return "", fmt.Errorf("error obtaining VCS status for %s: %s\n"+ + "\tUse -buildvcs=false to disable VCS stamping.", main.Module.Dir, reason) + default: + mainVersion = "(devel)" + } + } + } + + var b strings.Builder + b.WriteString("path\t") + b.WriteString(main.ImportPath) + b.WriteByte('\n') + b.WriteString("mod\t") + b.WriteString(main.Module.Path) + b.WriteByte('\t') + b.WriteString(mainVersion) + b.WriteString("\t\n") // trailing tab leaves the checksum column empty + for _, path := range deps { + b.WriteString("dep\t") + b.WriteString(path) + b.WriteByte('\t') + b.WriteString(depVersions[path]) + b.WriteString("\t\n") // go list -json carries no checksum; leave it empty + } + // Build settings (vcs.*) come after the module lines, matching + // runtime/debug.BuildInfo.String(). + b.WriteString(vcsSettings) + return b.String(), nil +} + +// gitVCSStamp derives the main-module version and the vcs.* build settings from +// the git checkout at dir, mirroring what the standard `go build` toolchain +// records under -buildvcs. modPath is the main module's path, used to reject a +// version that belongs to some other module. +// +// It returns ("", "", nil) when there is simply no stamp to make — dir is not a +// git work tree, or git is unavailable — so the caller falls back to "(devel)". +// The error is non-nil only when git itself failed in a way worth reporting, +// which -buildvcs=true turns into a hard failure. +// resolvePath makes an absolute path with symbolic links resolved. +func resolvePath(p string) (string, error) { + abs, err := filepath.Abs(p) + if err != nil { + return "", err + } + return filepath.EvalSymlinks(abs) +} + +func gitVCSStamp(dir, modPath string) (version, settings string, gitErr error) { + if dir == "" { + return "", "", nil + } + // Keep the first git failure so -buildvcs=true can report the true cause. + var firstErr error + git := func(args ...string) (string, bool) { + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + if firstErr == nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + firstErr = fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) + } + return "", false + } + return strings.TrimSpace(string(out)), true + } + if out, ok := git("rev-parse", "--is-inside-work-tree"); !ok || out != "true" { + return "", "", firstErr + } + // Tags found from the module directory can belong to a parent repository. + // Only stamp when the module is at the repository root, as the go tool does. + root, ok := git("rev-parse", "--show-toplevel") + if !ok { + return "", "", firstErr + } + // git gives the physical path but go list can give one with a symbolic link. + // Resolve both before the comparison, and do not stamp if that is not possible. + absDir, dirErr := resolvePath(dir) + absRoot, rootErr := resolvePath(root) + if dirErr != nil || rootErr != nil || absDir != absRoot { + return "", "", nil + } + // The module path major version decides which tags can belong to this module. + // See https://go.dev/ref/mod#major-version-suffixes + major, tagMatch := "", "v[0-9]*" + if _, pathMajor, ok := module.SplitPathVersion(modPath); ok && pathMajor != "" { + major = strings.TrimLeft(pathMajor, "/.") + tagMatch = major + ".*" + } + rev, ok := git("rev-parse", "HEAD") + if !ok || rev == "" { + // A repository with no commits has no revision. The go tool still records + // vcs and vcs.modified here and uses (devel). See cmd/go/internal/load/pkg.go + if inside, ok := git("rev-parse", "--is-bare-repository"); ok && inside == "false" { + var sb strings.Builder + sb.WriteString("build\tvcs=git\n") + sb.WriteString("build\tvcs.modified=true\n") + return "", sb.String(), nil + } + return "", "", firstErr + } + + // An unparsable commit time is a stamp failure. module.PseudoVersion would + // otherwise encode the zero time as 00010101000000 and look real. + var commitTime time.Time + s, ok := git("show", "-s", "--format=%ct", "HEAD") + if !ok { + return "", "", firstErr + } + sec, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return "", "", fmt.Errorf("git show -s --format=%%ct: unparsable commit time %q", s) + } + commitTime = time.Unix(sec, 0).UTC() + + // The tree is modified when git status reports any change, tracked or not. + // This is what go build does. + modified := false + if status, ok := git("status", "--porcelain"); ok && status != "" { + modified = true + } + + // Use an exact semver tag on HEAD, else a pseudo-version from the last tag. + if tags, ok := git("tag", "--points-at", "HEAD"); ok { + for _, t := range strings.Fields(tags) { + if semver.IsValid(t) && semver.Canonical(t) == t { + version = t + break + } + } + } + if version == "" { + older := "" + if base, ok := git("describe", "--tags", "--abbrev=0", "--match", tagMatch); ok && semver.IsValid(base) { + older = base + } + short := rev + if len(short) > 12 { + short = short[:12] + } + pseudoMajor := major + if pseudoMajor == "" { + pseudoMajor = semver.Major(older) + } + version = module.PseudoVersion(pseudoMajor, older, commitTime, short) + } + + // Final safety check on the major version only. module.Check would also + // reject an unpublished module path with no dot in its first element. + if _, pathMajor, ok := module.SplitPathVersion(modPath); ok { + if err := module.CheckPathMajor(version, pathMajor); err != nil { + return "", "", nil // not our version, fall back to (devel) + } + } + + // A modified tree no longer describes the tagged commit. + if modified { + version += "+dirty" + } + + var sb strings.Builder + sb.WriteString("build\tvcs=git\n") + sb.WriteString("build\tvcs.revision=" + rev + "\n") + // RFC3339Nano is the format cmd/go writes. + sb.WriteString("build\tvcs.time=" + commitTime.Format(time.RFC3339Nano) + "\n") + sb.WriteString("build\tvcs.modified=" + strconv.FormatBool(modified) + "\n") + return version, sb.String(), nil +} diff --git a/compileopts/options.go b/compileopts/options.go index 9b06611c55..750dcb42dc 100644 --- a/compileopts/options.go +++ b/compileopts/options.go @@ -17,6 +17,7 @@ var ( validPanicStrategyOptions = []string{"print", "trap"} validPanicUnwindOptions = []string{"auto", "explicit"} validOptOptions = []string{"none", "0", "1", "2", "s", "z"} + validBuildVCSOptions = []string{"auto", "true", "false"} ) // Options contains extra options to give to the compiler. These options are @@ -64,7 +65,8 @@ type Options struct { WITPackage string // pass through to wasm-tools component embed invocation WITWorld string // pass through to wasm-tools component embed -w option ExtLDFlags []string - GoCompatibility bool // enable to check for Go version compatibility + GoCompatibility bool // enable to check for Go version compatibility + BuildVCS string // -buildvcs: "auto" (default), "true" or "false" } // Verify performs a validation on the given options, raising an error if options are not valid. @@ -137,5 +139,11 @@ func (o *Options) Verify() error { } } + if o.BuildVCS != "" { + if !slices.Contains(validBuildVCSOptions, o.BuildVCS) { + return fmt.Errorf("invalid -buildvcs=%s: valid values are %s", o.BuildVCS, strings.Join(validBuildVCSOptions, ", ")) + } + } + return nil } diff --git a/compileopts/options_test.go b/compileopts/options_test.go index 094da03593..5766803d31 100644 --- a/compileopts/options_test.go +++ b/compileopts/options_test.go @@ -14,6 +14,7 @@ func TestVerifyOptions(t *testing.T) { expectedPrintSizeError := errors.New(`invalid size option 'incorrect': valid values are none, short, full, html`) expectedPanicStrategyError := errors.New(`invalid panic option 'incorrect': valid values are print, trap`) expectedPanicUnwindError := errors.New(`invalid panic-unwind option 'incorrect': valid values are auto, explicit`) + expectedBuildVCSError := errors.New(`invalid -buildvcs=incorrect: valid values are auto, true, false`) testCases := []struct { name string @@ -137,6 +138,31 @@ func TestVerifyOptions(t *testing.T) { PanicUnwind: "explicit", }, }, + { + name: "InvalidBuildVCSOption", + opts: compileopts.Options{ + BuildVCS: "incorrect", + }, + expectedError: expectedBuildVCSError, + }, + { + name: "BuildVCSOptionAuto", + opts: compileopts.Options{ + BuildVCS: "auto", + }, + }, + { + name: "BuildVCSOptionTrue", + opts: compileopts.Options{ + BuildVCS: "true", + }, + }, + { + name: "BuildVCSOptionFalse", + opts: compileopts.Options{ + BuildVCS: "false", + }, + }, } for _, tc := range testCases { diff --git a/go.mod b/go.mod index 5360718d6d..d0e20edceb 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( go.bug.st/serial v1.8.0 go.bytecodealliance.org v0.6.2 go.bytecodealliance.org/cm v0.2.2 + golang.org/x/mod v0.37.0 golang.org/x/net v0.56.0 golang.org/x/sys v0.47.0 golang.org/x/tools v0.47.0 @@ -47,6 +48,5 @@ require ( github.com/spf13/afero v1.11.0 // indirect github.com/ulikunitz/xz v0.5.12 // indirect github.com/urfave/cli/v3 v3.0.0-beta1 // indirect - golang.org/x/mod v0.37.0 // indirect golang.org/x/text v0.38.0 // indirect ) diff --git a/loader/loader.go b/loader/loader.go index 5696abd065..df46d662fa 100644 --- a/loader/loader.go +++ b/loader/loader.go @@ -56,6 +56,7 @@ type PackageJSON struct { Dir string GoMod string GoVersion string + Version string // module version, e.g. "v1.2.3" (empty for the main module) } // Source files diff --git a/main.go b/main.go index 3644270e4a..57fcf83e3a 100644 --- a/main.go +++ b/main.go @@ -1776,6 +1776,7 @@ func main() { flag.Var(&tags, "tags", "a space-separated list of extra build tags") target := flag.String("target", "", "chip/board name or JSON target specification file") buildMode := flag.String("buildmode", "", "build mode to use (default, c-shared, wasi-legacy)") + buildVCS := flag.String("buildvcs", "auto", "whether to stamp version control information (true, false, auto)") var stackSize uint64 flag.Func("stack-size", "goroutine stack size (if unknown at compile time)", func(s string) error { size, err := bytesize.Parse(s) @@ -1902,6 +1903,7 @@ func main() { GOMIPS: goenv.Get("GOMIPS"), Target: *target, BuildMode: *buildMode, + BuildVCS: *buildVCS, StackSize: stackSize, Opt: *opt, GC: *gc, diff --git a/main_test.go b/main_test.go index ee32cbcbf7..1be5e23c4e 100644 --- a/main_test.go +++ b/main_test.go @@ -55,6 +55,7 @@ func TestBuild(t *testing.T) { "alias.go", "atomic.go", "binop.go", + "buildinfo.go", "calls.go", "cgo/", "channel.go", @@ -414,7 +415,7 @@ func runPlatTests(options compileopts.Options, tests []string, t *testing.T) { // Does not pass due to high mark false positive rate. continue - case "json.go", "stdlib.go", "testing.go": + case "buildinfo.go", "json.go", "stdlib.go", "testing.go": // Too big for AVR. Doesn't fit in flash/RAM. continue diff --git a/src/runtime/debug/debug.go b/src/runtime/debug/debug.go index 38e6ab763b..ae9a0ea8ba 100644 --- a/src/runtime/debug/debug.go +++ b/src/runtime/debug/debug.go @@ -6,7 +6,7 @@ package debug import ( - "fmt" + "errors" "runtime" "strconv" "strings" @@ -32,13 +32,203 @@ func Stack() []byte { return nil } +// modinfo holds the serialized module build information for the running binary, +// in the same textual format produced by BuildInfo.String() (minus the leading +// "go\t..." line). It is empty unless the TinyGo builder embedded it (see +// builder.Build, which fills runtime/debug.modinfo from `go list -json`), or it +// was set explicitly via -ldflags="-X runtime/debug.modinfo=...". +// +// TinyGo controls both the writer and this reader, so the value is stored bare: +// it carries none of the delimiters cmd/go wraps around runtime.modinfo. +// +// What TinyGo writes is deliberately a subset. There are no "=>" replacement +// lines and no checksums, and none of the build settings the go toolchain +// records (-tags, GOOS, GOARCH, CGO_ENABLED and so on) beyond the vcs.* ones. +var modinfo string + // ReadBuildInfo returns the build information embedded // in the running binary. The information is available only // in binaries built with module support. // -// Not implemented. +// TinyGo populates GoVersion always, and Path/Main/Deps/Settings when the +// builder (or -ldflags -X) embedded module info; see the modinfo var. func ReadBuildInfo() (info *BuildInfo, ok bool) { - return &BuildInfo{GoVersion: runtime.Compiler + runtime.Version()}, true + goVersion := runtime.Compiler + runtime.Version() + data := modinfo + if data == "" { + // No module info embedded; still report the toolchain version so + // callers that only want GoVersion keep working. + return &BuildInfo{GoVersion: goVersion}, true + } + bi, err := ParseBuildInfo(data) + if err != nil { + return &BuildInfo{GoVersion: goVersion}, true + } + // GoVersion is stored separately from the module string (as in upstream Go). + bi.GoVersion = goVersion + return bi, true +} + +// parseError reports which line of the module string failed to parse. +// +// Upstream spells this fmt.Errorf("...: line %d: %w", ...). Doing the same +// here would link fmt into every binary that calls ReadBuildInfo, which on a +// small target costs more flash than the feature is worth, so the wrapping is +// written out by hand. The message is identical and Unwrap keeps errors.Is +// and errors.As working as they do upstream. +type parseError struct { + line int + err error +} + +func (e *parseError) Error() string { + return "could not parse Go build info: line " + strconv.Itoa(e.line) + ": " + e.err.Error() +} + +func (e *parseError) Unwrap() error { return e.err } + +// ParseBuildInfo parses the string returned by BuildInfo.String (excluding the +// leading "go" line) back into a BuildInfo. It is the reverse of that method +// and is ported from the standard library's runtime/debug. +func ParseBuildInfo(data string) (bi *BuildInfo, err error) { + lineNum := 1 + defer func() { + if err != nil { + err = &parseError{line: lineNum, err: err} + } + }() + + const ( + pathLine = "path\t" + modLine = "mod\t" + depLine = "dep\t" + repLine = "=>\t" + buildLine = "build\t" + newline = "\n" + tab = "\t" + ) + + readModuleLine := func(elem []string) (Module, error) { + if len(elem) != 2 && len(elem) != 3 { + return Module{}, errors.New("expected 2 or 3 columns; got " + strconv.Itoa(len(elem))) + } + version := elem[1] + sum := "" + if len(elem) == 3 { + sum = elem[2] + } + return Module{ + Path: elem[0], + Version: version, + Sum: sum, + }, nil + } + + bi = new(BuildInfo) + var ( + last *Module + line string + ok bool + ) + // Reverse of BuildInfo.String(), except for go version. + for len(data) > 0 { + line, data, ok = strings.Cut(data, newline) + if !ok { + break + } + switch { + case strings.HasPrefix(line, pathLine): + elem := line[len(pathLine):] + bi.Path = elem + case strings.HasPrefix(line, modLine): + elem := strings.Split(line[len(modLine):], tab) + last = &bi.Main + *last, err = readModuleLine(elem) + if err != nil { + return nil, err + } + case strings.HasPrefix(line, depLine): + elem := strings.Split(line[len(depLine):], tab) + last = new(Module) + bi.Deps = append(bi.Deps, last) + *last, err = readModuleLine(elem) + if err != nil { + return nil, err + } + case strings.HasPrefix(line, repLine): + elem := strings.Split(line[len(repLine):], tab) + if len(elem) != 3 { + return nil, errors.New("expected 3 columns for replacement; got " + strconv.Itoa(len(elem))) + } + if last == nil { + return nil, errors.New("replacement with no module on previous line") + } + last.Replace = &Module{ + Path: elem[0], + Version: elem[1], + Sum: elem[2], + } + last = nil + case strings.HasPrefix(line, buildLine): + kv := line[len(buildLine):] + if len(kv) < 1 { + return nil, errors.New("build line missing '='") + } + + var key, rawValue string + switch kv[0] { + case '=': + return nil, errors.New("build line with missing key") + + case '`', '"': + rawKey, err := strconv.QuotedPrefix(kv) + if err != nil { + return nil, errors.New("invalid quoted key in build line") + } + if len(kv) == len(rawKey) { + return nil, errors.New("build line missing '=' after quoted key") + } + if c := kv[len(rawKey)]; c != '=' { + // %q on a byte formats a single-quoted rune, not a string. + return nil, errors.New("unexpected character after quoted key: " + strconv.QuoteRune(rune(c))) + } + key, _ = strconv.Unquote(rawKey) + rawValue = kv[len(rawKey)+1:] + + default: + var ok bool + key, rawValue, ok = strings.Cut(kv, "=") + if !ok { + return nil, errors.New("build line missing '=' after key") + } + if quoteKey(key) { + return nil, errors.New("unquoted key " + strconv.Quote(key) + " must be quoted") + } + } + + var value string + if len(rawValue) > 0 { + switch rawValue[0] { + case '`', '"': + var err error + value, err = strconv.Unquote(rawValue) + if err != nil { + return nil, errors.New("invalid quoted value in build line") + } + + default: + value = rawValue + if quoteValue(value) { + return nil, errors.New("unquoted value " + strconv.Quote(value) + " must be quoted") + } + } + } + + bi.Settings = append(bi.Settings, BuildSetting{Key: key, Value: value}) + } + lineNum++ + } + return bi, nil } // BuildInfo represents the build information read from @@ -86,10 +276,14 @@ func quoteValue(value string) bool { func (bi *BuildInfo) String() string { buf := new(strings.Builder) if bi.GoVersion != "" { - fmt.Fprintf(buf, "go\t%s\n", bi.GoVersion) + buf.WriteString("go\t") + buf.WriteString(bi.GoVersion) + buf.WriteByte('\n') } if bi.Path != "" { - fmt.Fprintf(buf, "path\t%s\n", bi.Path) + buf.WriteString("path\t") + buf.WriteString(bi.Path) + buf.WriteByte('\n') } var formatMod func(string, Module) formatMod = func(word string, m Module) { @@ -122,7 +316,11 @@ func (bi *BuildInfo) String() string { if quoteValue(value) { value = strconv.Quote(value) } - fmt.Fprintf(buf, "build\t%s=%s\n", key, value) + buf.WriteString("build\t") + buf.WriteString(key) + buf.WriteByte('=') + buf.WriteString(value) + buf.WriteByte('\n') } return buf.String() diff --git a/testdata/buildinfo.go b/testdata/buildinfo.go new file mode 100644 index 0000000000..f8687e5aa8 --- /dev/null +++ b/testdata/buildinfo.go @@ -0,0 +1,119 @@ +package main + +// Tests runtime/debug.ReadBuildInfo and the parser behind it. +// +// The output has to be the same on every machine, so nothing here prints a +// version, a module path or a checksum that the build happens to have. What is +// printed is either derived (does the version exist, does it name the +// compiler) or comes from the fixed string parsed below. + +import ( + "runtime" + "runtime/debug" + "strings" +) + +// A module string in the format BuildInfo.String writes, minus the leading go +// line — which is what ParseBuildInfo takes. Covers the four line kinds: path, +// mod, dep and build, plus a replaced dependency. +const sample = "path\texample.com/prog\n" + + "mod\texample.com/prog\t(devel)\t\n" + + "dep\texample.com/a\tv1.2.3\th1:aaa=\n" + + "dep\texample.com/b\tv0.1.0\t\n" + + "=>\texample.com/b-fork\tv0.2.0\th1:bbb=\n" + + "build\t-tags=sample\n" + + "build\tCGO_ENABLED=0\n" + +func main() { + readBuildInfo() + parse() + roundTrip() + malformed() +} + +func readBuildInfo() { + info, ok := debug.ReadBuildInfo() + // ReadBuildInfo always succeeds: with no module information embedded it + // still reports the toolchain version, so callers that only want + // GoVersion keep working. + println("read ok:", ok) + println("info not nil:", info != nil) + if info == nil { + return + } + println("go version set:", info.GoVersion != "") + println("names the compiler:", strings.HasPrefix(info.GoVersion, runtime.Compiler)) +} + +func parse() { + bi, err := debug.ParseBuildInfo(sample) + if err != nil { + println("parse error:", err.Error()) + return + } + println("path:", bi.Path) + println("main path:", bi.Main.Path) + println("main version:", bi.Main.Version) + println("deps:", len(bi.Deps)) + for _, d := range bi.Deps { + println("dep:", d.Path, d.Version, d.Sum) + if d.Replace != nil { + println(" replaced by:", d.Replace.Path, d.Replace.Version, d.Replace.Sum) + } + } + println("settings:", len(bi.Settings)) + for _, s := range bi.Settings { + println("setting:", s.Key, s.Value) + } +} + +func roundTrip() { + bi, err := debug.ParseBuildInfo(sample) + if err != nil { + println("round trip parse error:", err.Error()) + return + } + // String writes a leading go line that ParseBuildInfo does not read, so it + // is dropped before parsing again. Everything else must survive. + out := bi.String() + if i := strings.Index(out, "\n"); i >= 0 && strings.HasPrefix(out, "go\t") { + out = out[i+1:] + } + again, err := debug.ParseBuildInfo(out) + if err != nil { + println("round trip reparse error:", err.Error()) + return + } + println("round trip path:", again.Path == bi.Path) + println("round trip main:", again.Main == bi.Main) + println("round trip deps:", len(again.Deps) == len(bi.Deps)) + println("round trip settings:", len(again.Settings) == len(bi.Settings)) +} + +func malformed() { + // A line whose prefix is not one of the known kinds is skipped rather than + // rejected, which is what upstream does and what keeps an older parser + // reading a newer module string. + for _, in := range []string{ + "path\n", // "path" without the tab is not the path line + "build\n", // likewise + "future\tsomething\n", // a line kind this version does not know + } { + _, err := debug.ParseBuildInfo(in) + println("unknown line skipped:", err == nil) + } + + // A line that is one of the known kinds but the wrong shape is an error, + // because that is a module string this parser has misread rather than one + // it does not recognize. ReadBuildInfo falls back to the toolchain version + // when that happens. + for _, in := range []string{ + "mod\texample.com/prog\n", // a module needs 2 or 3 columns + "dep\texample.com/a\n", // likewise + "=>\texample.com/x\tv1.0.0\th1:x=\n", // a replacement with nothing to replace + "mod\ta\tb\tc\td\n", // too many columns + } { + _, err := debug.ParseBuildInfo(in) + println("malformed rejected:", err != nil) + } +} diff --git a/testdata/buildinfo.txt b/testdata/buildinfo.txt new file mode 100644 index 0000000000..f2bb702781 --- /dev/null +++ b/testdata/buildinfo.txt @@ -0,0 +1,25 @@ +read ok: true +info not nil: true +go version set: true +names the compiler: true +path: example.com/prog +main path: example.com/prog +main version: (devel) +deps: 2 +dep: example.com/a v1.2.3 h1:aaa= +dep: example.com/b v0.1.0 + replaced by: example.com/b-fork v0.2.0 h1:bbb= +settings: 2 +setting: -tags sample +setting: CGO_ENABLED 0 +round trip path: true +round trip main: true +round trip deps: true +round trip settings: true +unknown line skipped: true +unknown line skipped: true +unknown line skipped: true +malformed rejected: true +malformed rejected: true +malformed rejected: true +malformed rejected: true