diff --git a/builder/build.go b/builder/build.go index 974ddc2a37..637978d3f0 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" ) @@ -249,6 +252,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() { @@ -1603,3 +1626,230 @@ 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 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. +func gitVCSStamp(dir, modPath string) (version, settings string, gitErr error) { + if dir == "" { + return "", "", nil + } + // Keep the first real git failure, so -buildvcs=true can say what actually + // went wrong. "not a work tree" and a refusal over safe.directory or file + // permissions are very different problems and should not share a message. + 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 + } + // The tags below are searched from the module directory, so they can belong + // to a parent repository. Only stamp when the module is itself the thing the + // repository is versioning; otherwise a monorepo, or a nested module, gets a + // version that is not its own. The go toolchain makes the same check. + root, ok := git("rev-parse", "--show-toplevel") + if !ok { + return "", "", firstErr + } + if absDir, err := filepath.Abs(dir); err == nil { + if absRoot, err := filepath.Abs(root); err == nil && absDir != absRoot { + return "", "", nil // module is not at the repository root; no stamp + } + } + rev, ok := git("rev-parse", "HEAD") + if !ok || rev == "" { + return "", "", firstErr + } + + // Commit time (Unix seconds → UTC), used in the pseudo-version and vcs.time. + // A missing or unparsable time is a stamp failure rather than something to + // paper over: module.PseudoVersion would otherwise encode the zero time as + // 00010101000000 and produce a version that looks real but is not. + 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" if there are any uncommitted changes (tracked or + // untracked), as reported by `git status --porcelain` — same as `go build`. + modified := false + if status, ok := git("status", "--porcelain"); ok && status != "" { + modified = true + } + + // Version: an exact semver tag pointing at HEAD, else a Go-style + // pseudo-version based on the most recent reachable 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", "v[0-9]*"); ok && semver.IsValid(base) { + older = base + } + short := rev + if len(short) > 12 { + short = short[:12] + } + version = module.PseudoVersion(semver.Major(older), older, commitTime, short) + } + + // The version came from whatever tags git found, which says nothing about + // the module path. Reject the mismatch that produces: a v1 tag on a module + // whose path ends in /v2, and the reverse. + // + // This deliberately checks only the major version, not the whole path. + // module.Check would also reject a path with no dot in its first element, + // which is a perfectly ordinary thing for a module that is never published, + // and stripping the stamp from those would be a regression. + if _, pathMajor, ok := module.SplitPathVersion(modPath); ok { + if err := module.CheckPathMajor(version, pathMajor); err != nil { + return "", "", nil // not our version to claim; fall back to (devel) + } + } + + // A modified work tree does not describe the tagged commit any more, so say + // so, exactly as `go build` does. + if modified { + version += "+dirty" + } + + var sb strings.Builder + sb.WriteString("build\tvcs=git\n") + sb.WriteString("build\tvcs.revision=" + rev + "\n") + // RFC3339Nano, matching cmd/go. %ct only gives whole seconds, so nothing is + // lost either way, but the format should be the one Go readers expect. + 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 cb5f24d5c0..69bb6b54e5 100644 --- a/compileopts/options.go +++ b/compileopts/options.go @@ -16,6 +16,7 @@ var ( validPrintSizeOptions = []string{"none", "short", "full", "html"} validPanicStrategyOptions = []string{"print", "trap"} 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 @@ -62,7 +63,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. @@ -126,5 +128,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 dd098e6c4a..b694bfc023 100644 --- a/compileopts/options_test.go +++ b/compileopts/options_test.go @@ -13,6 +13,7 @@ func TestVerifyOptions(t *testing.T) { expectedSchedulerError := errors.New(`invalid scheduler option 'incorrect': valid values are none, tasks, asyncify, threads, cores`) 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`) + expectedBuildVCSError := errors.New(`invalid -buildvcs=incorrect: valid values are auto, true, false`) testCases := []struct { name string @@ -117,6 +118,31 @@ func TestVerifyOptions(t *testing.T) { PanicStrategy: "trap", }, }, + { + 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 b79c457c11..782c725df0 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 986e8c176a..757b3c5cf4 100644 --- a/main.go +++ b/main.go @@ -1775,6 +1775,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) @@ -1901,6 +1902,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 2dfd1683f6..1ff437fcf1 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", @@ -329,7 +330,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