debug/buildinfo: new package with Read and ReadFile

These functions provide access to module information stamped into Go
binaries. In the future, they'll provide access to other information
(like VCS info).

These functions are added in a new package instead of runtime/debug
since they use binary parsing packages like debug/elf, which would
make runtime/debug an unacceptably heavy dependency. The types in
runtime/debug are still used; debug/buildinfo uses them via type
aliases.

This information is already available for the running binary through
debug.ReadBuildInfo and for other binaries with 'go version -m', but
until now, there hasn't been a way to get it for other binaries
without installing cmd/go.

This change copies most of the code in cmd/go/internal/version. A
later CL will migrate 'go version -m' to use this package.

For #37475
Fixes #39301

Change-Id: I0fbe0896e04f12ef81c6d79fb61b20daede86159
Reviewed-on: https://go-review.googlesource.com/c/go/+/353887
Trust: Jay Conrod <jayconrod@google.com>
Run-TryBot: Jay Conrod <jayconrod@google.com>
Reviewed-by: Bryan C. Mills <bcmills@google.com>
diff --git a/api/next.txt b/api/next.txt
index cb729ea..0a976d7 100644
--- a/api/next.txt
+++ b/api/next.txt
@@ -1,4 +1,8 @@
+pkg debug/buildinfo, func Read(io.ReaderAt) (*debug.BuildInfo, error)
+pkg debug/buildinfo, func ReadFile(string) (*debug.BuildInfo, error)
+pkg debug/buildinfo, type BuildInfo = debug.BuildInfo
 pkg runtime/debug, method (*BuildInfo) MarshalText() ([]byte, error)
+pkg runtime/debug, method (*BuildInfo) UnmarshalText() ([]byte, error)
 pkg syscall (darwin-amd64), func RecvfromInet4(int, []uint8, int, *SockaddrInet4) (int, error)
 pkg syscall (darwin-amd64), func RecvfromInet6(int, []uint8, int, *SockaddrInet6) (int, error)
 pkg syscall (darwin-amd64), func SendtoInet4(int, []uint8, int, SockaddrInet4) error
diff --git a/src/debug/buildinfo/buildinfo.go b/src/debug/buildinfo/buildinfo.go
new file mode 100644
index 0000000..8def2ea
--- /dev/null
+++ b/src/debug/buildinfo/buildinfo.go
@@ -0,0 +1,374 @@
+// Copyright 2021 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 buildinfo provides access to information embedded in a Go binary
+// about how it was built. This includes the Go toolchain version, and the
+// set of modules used (for binaries built in module mode).
+//
+// Build information is available for the currently running binary in
+// runtime/debug.ReadBuildInfo.
+package buildinfo
+
+import (
+	"bytes"
+	"debug/elf"
+	"debug/macho"
+	"debug/pe"
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"internal/xcoff"
+	"io"
+	"io/fs"
+	"os"
+	"runtime/debug"
+)
+
+// Type alias for build info. We cannot move the types here, since
+// runtime/debug would need to import this package, which would make it
+// a much larger dependency.
+type BuildInfo = debug.BuildInfo
+
+var (
+	// errUnrecognizedFormat is returned when a given executable file doesn't
+	// appear to be in a known format, or it breaks the rules of that format,
+	// or when there are I/O errors reading the file.
+	errUnrecognizedFormat = errors.New("unrecognized file format")
+
+	// errNotGoExe is returned when a given executable file is valid but does
+	// not contain Go build information.
+	errNotGoExe = errors.New("not a Go executable")
+
+	// The build info blob left by the linker is identified by
+	// a 16-byte header, consisting of buildInfoMagic (14 bytes),
+	// the binary's pointer size (1 byte),
+	// and whether the binary is big endian (1 byte).
+	buildInfoMagic = []byte("\xff Go buildinf:")
+)
+
+// ReadFile returns build information embedded in a Go binary
+// file at the given path. Most information is only available for binaries built
+// with module support.
+func ReadFile(name string) (info *BuildInfo, err error) {
+	defer func() {
+		if pathErr := (*fs.PathError)(nil); errors.As(err, &pathErr) {
+			err = fmt.Errorf("could not read Go build info: %w", err)
+		} else if err != nil {
+			err = fmt.Errorf("could not read Go build info from %s: %w", name, err)
+		}
+	}()
+
+	f, err := os.Open(name)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	return Read(f)
+}
+
+// Read returns build information embedded in a Go binary file
+// accessed through the given ReaderAt. Most information is only available for
+// binaries built with module support.
+func Read(r io.ReaderAt) (*BuildInfo, error) {
+	_, mod, err := readRawBuildInfo(r)
+	if err != nil {
+		return nil, err
+	}
+	bi := &BuildInfo{}
+	if err := bi.UnmarshalText([]byte(mod)); err != nil {
+		return nil, err
+	}
+	return bi, nil
+}
+
+type exe interface {
+	// ReadData reads and returns up to size bytes starting at virtual address addr.
+	ReadData(addr, size uint64) ([]byte, error)
+
+	// DataStart returns the virtual address of the segment or section that
+	// should contain build information. This is either a specially named section
+	// or the first writable non-zero data segment.
+	DataStart() uint64
+}
+
+// readRawBuildInfo extracts the Go toolchain version and module information
+// strings from a Go binary. On success, vers should be non-empty. mod
+// is empty if the binary was not built with modules enabled.
+func readRawBuildInfo(r io.ReaderAt) (vers, mod string, err error) {
+	// Read the first bytes of the file to identify the format, then delegate to
+	// a format-specific function to load segment and section headers.
+	ident := make([]byte, 16)
+	if n, err := r.ReadAt(ident, 0); n < len(ident) || err != nil {
+		return "", "", errUnrecognizedFormat
+	}
+
+	var x exe
+	switch {
+	case bytes.HasPrefix(ident, []byte("\x7FELF")):
+		f, err := elf.NewFile(r)
+		if err != nil {
+			return "", "", errUnrecognizedFormat
+		}
+		x = &elfExe{f}
+	case bytes.HasPrefix(ident, []byte("MZ")):
+		f, err := pe.NewFile(r)
+		if err != nil {
+			return "", "", errUnrecognizedFormat
+		}
+		x = &peExe{f}
+	case bytes.HasPrefix(ident, []byte("\xFE\xED\xFA")) || bytes.HasPrefix(ident[1:], []byte("\xFA\xED\xFE")):
+		f, err := macho.NewFile(r)
+		if err != nil {
+			return "", "", errUnrecognizedFormat
+		}
+		x = &machoExe{f}
+	case bytes.HasPrefix(ident, []byte{0x01, 0xDF}) || bytes.HasPrefix(ident, []byte{0x01, 0xF7}):
+		f, err := xcoff.NewFile(r)
+		if err != nil {
+			return "", "", errUnrecognizedFormat
+		}
+		x = &xcoffExe{f}
+	default:
+		return "", "", errUnrecognizedFormat
+	}
+
+	// Read the first 64kB of dataAddr to find the build info blob.
+	// On some platforms, the blob will be in its own section, and DataStart
+	// returns the address of that section. On others, it's somewhere in the
+	// data segment; the linker puts it near the beginning.
+	// See cmd/link/internal/ld.Link.buildinfo.
+	dataAddr := x.DataStart()
+	data, err := x.ReadData(dataAddr, 64*1024)
+	if err != nil {
+		return "", "", err
+	}
+	const (
+		buildInfoAlign = 16
+		buildinfoSize  = 32
+	)
+	for ; !bytes.HasPrefix(data, buildInfoMagic); data = data[buildInfoAlign:] {
+		if len(data) < 32 {
+			return "", "", errNotGoExe
+		}
+	}
+
+	// Decode the blob.
+	// The first 14 bytes are buildInfoMagic.
+	// The next two bytes indicate pointer size in bytes (4 or 8) and endianness
+	// (0 for little, 1 for big).
+	// Two virtual addresses to Go strings follow that: runtime.buildVersion,
+	// and runtime.modinfo.
+	// On 32-bit platforms, the last 8 bytes are unused.
+	ptrSize := int(data[14])
+	bigEndian := data[15] != 0
+	var bo binary.ByteOrder
+	if bigEndian {
+		bo = binary.BigEndian
+	} else {
+		bo = binary.LittleEndian
+	}
+	var readPtr func([]byte) uint64
+	if ptrSize == 4 {
+		readPtr = func(b []byte) uint64 { return uint64(bo.Uint32(b)) }
+	} else {
+		readPtr = bo.Uint64
+	}
+	vers = readString(x, ptrSize, readPtr, readPtr(data[16:]))
+	if vers == "" {
+		return "", "", errNotGoExe
+	}
+	mod = readString(x, ptrSize, readPtr, readPtr(data[16+ptrSize:]))
+	if len(mod) >= 33 && mod[len(mod)-17] == '\n' {
+		// Strip module framing: sentinel strings delimiting the module info.
+		// These are cmd/go/internal/modload.infoStart and infoEnd.
+		mod = mod[16 : len(mod)-16]
+	} else {
+		mod = ""
+	}
+
+	return vers, mod, nil
+}
+
+// readString returns the string at address addr in the executable x.
+func readString(x exe, ptrSize int, readPtr func([]byte) uint64, addr uint64) string {
+	hdr, err := x.ReadData(addr, uint64(2*ptrSize))
+	if err != nil || len(hdr) < 2*ptrSize {
+		return ""
+	}
+	dataAddr := readPtr(hdr)
+	dataLen := readPtr(hdr[ptrSize:])
+	data, err := x.ReadData(dataAddr, dataLen)
+	if err != nil || uint64(len(data)) < dataLen {
+		return ""
+	}
+	return string(data)
+}
+
+// elfExe is the ELF implementation of the exe interface.
+type elfExe struct {
+	f *elf.File
+}
+
+func (x *elfExe) ReadData(addr, size uint64) ([]byte, error) {
+	for _, prog := range x.f.Progs {
+		if prog.Vaddr <= addr && addr <= prog.Vaddr+prog.Filesz-1 {
+			n := prog.Vaddr + prog.Filesz - addr
+			if n > size {
+				n = size
+			}
+			data := make([]byte, n)
+			_, err := prog.ReadAt(data, int64(addr-prog.Vaddr))
+			if err != nil {
+				return nil, err
+			}
+			return data, nil
+		}
+	}
+	return nil, errUnrecognizedFormat
+}
+
+func (x *elfExe) DataStart() uint64 {
+	for _, s := range x.f.Sections {
+		if s.Name == ".go.buildinfo" {
+			return s.Addr
+		}
+	}
+	for _, p := range x.f.Progs {
+		if p.Type == elf.PT_LOAD && p.Flags&(elf.PF_X|elf.PF_W) == elf.PF_W {
+			return p.Vaddr
+		}
+	}
+	return 0
+}
+
+// peExe is the PE (Windows Portable Executable) implementation of the exe interface.
+type peExe struct {
+	f *pe.File
+}
+
+func (x *peExe) imageBase() uint64 {
+	switch oh := x.f.OptionalHeader.(type) {
+	case *pe.OptionalHeader32:
+		return uint64(oh.ImageBase)
+	case *pe.OptionalHeader64:
+		return oh.ImageBase
+	}
+	return 0
+}
+
+func (x *peExe) ReadData(addr, size uint64) ([]byte, error) {
+	addr -= x.imageBase()
+	for _, sect := range x.f.Sections {
+		if uint64(sect.VirtualAddress) <= addr && addr <= uint64(sect.VirtualAddress+sect.Size-1) {
+			n := uint64(sect.VirtualAddress+sect.Size) - addr
+			if n > size {
+				n = size
+			}
+			data := make([]byte, n)
+			_, err := sect.ReadAt(data, int64(addr-uint64(sect.VirtualAddress)))
+			if err != nil {
+				return nil, errUnrecognizedFormat
+			}
+			return data, nil
+		}
+	}
+	return nil, errUnrecognizedFormat
+}
+
+func (x *peExe) DataStart() uint64 {
+	// Assume data is first writable section.
+	const (
+		IMAGE_SCN_CNT_CODE               = 0x00000020
+		IMAGE_SCN_CNT_INITIALIZED_DATA   = 0x00000040
+		IMAGE_SCN_CNT_UNINITIALIZED_DATA = 0x00000080
+		IMAGE_SCN_MEM_EXECUTE            = 0x20000000
+		IMAGE_SCN_MEM_READ               = 0x40000000
+		IMAGE_SCN_MEM_WRITE              = 0x80000000
+		IMAGE_SCN_MEM_DISCARDABLE        = 0x2000000
+		IMAGE_SCN_LNK_NRELOC_OVFL        = 0x1000000
+		IMAGE_SCN_ALIGN_32BYTES          = 0x600000
+	)
+	for _, sect := range x.f.Sections {
+		if sect.VirtualAddress != 0 && sect.Size != 0 &&
+			sect.Characteristics&^IMAGE_SCN_ALIGN_32BYTES == IMAGE_SCN_CNT_INITIALIZED_DATA|IMAGE_SCN_MEM_READ|IMAGE_SCN_MEM_WRITE {
+			return uint64(sect.VirtualAddress) + x.imageBase()
+		}
+	}
+	return 0
+}
+
+// machoExe is the Mach-O (Apple macOS/iOS) implementation of the exe interface.
+type machoExe struct {
+	f *macho.File
+}
+
+func (x *machoExe) ReadData(addr, size uint64) ([]byte, error) {
+	for _, load := range x.f.Loads {
+		seg, ok := load.(*macho.Segment)
+		if !ok {
+			continue
+		}
+		if seg.Addr <= addr && addr <= seg.Addr+seg.Filesz-1 {
+			if seg.Name == "__PAGEZERO" {
+				continue
+			}
+			n := seg.Addr + seg.Filesz - addr
+			if n > size {
+				n = size
+			}
+			data := make([]byte, n)
+			_, err := seg.ReadAt(data, int64(addr-seg.Addr))
+			if err != nil {
+				return nil, err
+			}
+			return data, nil
+		}
+	}
+	return nil, errUnrecognizedFormat
+}
+
+func (x *machoExe) DataStart() uint64 {
+	// Look for section named "__go_buildinfo".
+	for _, sec := range x.f.Sections {
+		if sec.Name == "__go_buildinfo" {
+			return sec.Addr
+		}
+	}
+	// Try the first non-empty writable segment.
+	const RW = 3
+	for _, load := range x.f.Loads {
+		seg, ok := load.(*macho.Segment)
+		if ok && seg.Addr != 0 && seg.Filesz != 0 && seg.Prot == RW && seg.Maxprot == RW {
+			return seg.Addr
+		}
+	}
+	return 0
+}
+
+// xcoffExe is the XCOFF (AIX eXtended COFF) implementation of the exe interface.
+type xcoffExe struct {
+	f *xcoff.File
+}
+
+func (x *xcoffExe) ReadData(addr, size uint64) ([]byte, error) {
+	for _, sect := range x.f.Sections {
+		if uint64(sect.VirtualAddress) <= addr && addr <= uint64(sect.VirtualAddress+sect.Size-1) {
+			n := uint64(sect.VirtualAddress+sect.Size) - addr
+			if n > size {
+				n = size
+			}
+			data := make([]byte, n)
+			_, err := sect.ReadAt(data, int64(addr-uint64(sect.VirtualAddress)))
+			if err != nil {
+				return nil, err
+			}
+			return data, nil
+		}
+	}
+	return nil, fmt.Errorf("address not mapped")
+}
+
+func (x *xcoffExe) DataStart() uint64 {
+	return x.f.SectionByType(xcoff.STYP_DATA).VirtualAddress
+}
diff --git a/src/debug/buildinfo/buildinfo_test.go b/src/debug/buildinfo/buildinfo_test.go
new file mode 100644
index 0000000..765bf24
--- /dev/null
+++ b/src/debug/buildinfo/buildinfo_test.go
@@ -0,0 +1,206 @@
+// Copyright 2021 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 buildinfo_test
+
+import (
+	"bytes"
+	"debug/buildinfo"
+	"flag"
+	"internal/testenv"
+	"os"
+	"os/exec"
+	"path"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+)
+
+var flagAll = flag.Bool("all", false, "test all supported GOOS/GOARCH platforms, instead of only the current platform")
+
+// TestReadFile confirms that ReadFile can read build information from binaries
+// on supported target platforms. It builds a trivial binary on the current
+// platforms (or all platforms if -all is set) in various configurations and
+// checks that build information can or cannot be read.
+func TestReadFile(t *testing.T) {
+	if testing.Short() {
+		t.Skip("test requires compiling and linking, which may be slow")
+	}
+	testenv.MustHaveGoBuild(t)
+
+	type platform struct{ goos, goarch string }
+	platforms := []platform{
+		{"aix", "ppc64"},
+		{"darwin", "amd64"},
+		{"darwin", "arm64"},
+		{"linux", "386"},
+		{"linux", "amd64"},
+		{"windows", "386"},
+		{"windows", "amd64"},
+	}
+	runtimePlatform := platform{runtime.GOOS, runtime.GOARCH}
+	haveRuntimePlatform := false
+	for _, p := range platforms {
+		if p == runtimePlatform {
+			haveRuntimePlatform = true
+			break
+		}
+	}
+	if !haveRuntimePlatform {
+		platforms = append(platforms, runtimePlatform)
+	}
+
+	buildWithModules := func(t *testing.T, goos, goarch string) string {
+		dir := t.TempDir()
+		gomodPath := filepath.Join(dir, "go.mod")
+		gomodData := []byte("module example.com/m\ngo 1.18\n")
+		if err := os.WriteFile(gomodPath, gomodData, 0666); err != nil {
+			t.Fatal(err)
+		}
+		helloPath := filepath.Join(dir, "hello.go")
+		helloData := []byte("package main\nfunc main() {}\n")
+		if err := os.WriteFile(helloPath, helloData, 0666); err != nil {
+			t.Fatal(err)
+		}
+		outPath := filepath.Join(dir, path.Base(t.Name()))
+		cmd := exec.Command("go", "build", "-o="+outPath)
+		cmd.Dir = dir
+		cmd.Env = append(os.Environ(), "GO111MODULE=on", "GOOS="+goos, "GOARCH="+goarch)
+		stderr := &bytes.Buffer{}
+		cmd.Stderr = stderr
+		if err := cmd.Run(); err != nil {
+			t.Fatalf("failed building test file: %v\n%s", err, stderr.Bytes())
+		}
+		return outPath
+	}
+
+	buildWithGOPATH := func(t *testing.T, goos, goarch string) string {
+		gopathDir := t.TempDir()
+		pkgDir := filepath.Join(gopathDir, "src/example.com/m")
+		if err := os.MkdirAll(pkgDir, 0777); err != nil {
+			t.Fatal(err)
+		}
+		helloPath := filepath.Join(pkgDir, "hello.go")
+		helloData := []byte("package main\nfunc main() {}\n")
+		if err := os.WriteFile(helloPath, helloData, 0666); err != nil {
+			t.Fatal(err)
+		}
+		outPath := filepath.Join(gopathDir, path.Base(t.Name()))
+		cmd := exec.Command("go", "build", "-o="+outPath)
+		cmd.Dir = pkgDir
+		cmd.Env = append(os.Environ(), "GO111MODULE=off", "GOPATH="+gopathDir, "GOOS="+goos, "GOARCH="+goarch)
+		stderr := &bytes.Buffer{}
+		cmd.Stderr = stderr
+		if err := cmd.Run(); err != nil {
+			t.Fatalf("failed building test file: %v\n%s", err, stderr.Bytes())
+		}
+		return outPath
+	}
+
+	damageBuildInfo := func(t *testing.T, name string) {
+		data, err := os.ReadFile(name)
+		if err != nil {
+			t.Fatal(err)
+		}
+		i := bytes.Index(data, []byte("\xff Go buildinf:"))
+		if i < 0 {
+			t.Fatal("Go buildinf not found")
+		}
+		data[i+2] = 'N'
+		if err := os.WriteFile(name, data, 0666); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	cases := []struct {
+		name    string
+		build   func(t *testing.T, goos, goarch string) string
+		want    string
+		wantErr string
+	}{
+		{
+			name: "doesnotexist",
+			build: func(t *testing.T, goos, goarch string) string {
+				return "doesnotexist.txt"
+			},
+			wantErr: "doesnotexist",
+		},
+		{
+			name: "empty",
+			build: func(t *testing.T, _, _ string) string {
+				dir := t.TempDir()
+				name := filepath.Join(dir, "empty")
+				if err := os.WriteFile(name, nil, 0666); err != nil {
+					t.Fatal(err)
+				}
+				return name
+			},
+			wantErr: "unrecognized file format",
+		},
+		{
+			name:  "valid_modules",
+			build: buildWithModules,
+			want: "path\texample.com/m\n" +
+				"mod\texample.com/m\t(devel)\t\n",
+		},
+		{
+			name: "invalid_modules",
+			build: func(t *testing.T, goos, goarch string) string {
+				name := buildWithModules(t, goos, goarch)
+				damageBuildInfo(t, name)
+				return name
+			},
+			wantErr: "not a Go executable",
+		},
+		{
+			name:  "valid_gopath",
+			build: buildWithGOPATH,
+			want:  "",
+		},
+		{
+			name: "invalid_gopath",
+			build: func(t *testing.T, goos, goarch string) string {
+				name := buildWithGOPATH(t, goos, goarch)
+				damageBuildInfo(t, name)
+				return name
+			},
+			wantErr: "not a Go executable",
+		},
+	}
+
+	for _, p := range platforms {
+		p := p
+		t.Run(p.goos+"_"+p.goarch, func(t *testing.T) {
+			if p != runtimePlatform && !*flagAll {
+				t.Skipf("skipping platforms other than %s_%s because -all was not set", runtimePlatform.goos, runtimePlatform.goarch)
+			}
+			for _, tc := range cases {
+				tc := tc
+				t.Run(tc.name, func(t *testing.T) {
+					t.Parallel()
+					name := tc.build(t, p.goos, p.goarch)
+					if info, err := buildinfo.ReadFile(name); err != nil {
+						if tc.wantErr == "" {
+							t.Fatalf("unexpected error: %v", err)
+						} else if errMsg := err.Error(); !strings.Contains(errMsg, tc.wantErr) {
+							t.Fatalf("got error %q; want error containing %q", errMsg, tc.wantErr)
+						}
+					} else {
+						if tc.wantErr != "" {
+							t.Fatalf("unexpected success; want error containing %q", tc.wantErr)
+						} else if got, err := info.MarshalText(); err != nil {
+							t.Fatalf("unexpected error marshaling BuildInfo: %v", err)
+						} else {
+							got := string(got)
+							if got != tc.want {
+								t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want)
+							}
+						}
+					}
+				})
+			}
+		})
+	}
+}
diff --git a/src/go/build/deps_test.go b/src/go/build/deps_test.go
index 07fbc8b..a92bb38 100644
--- a/src/go/build/deps_test.go
+++ b/src/go/build/deps_test.go
@@ -214,7 +214,6 @@
 	  mime/quotedprintable,
 	  net/internal/socktest,
 	  net/url,
-	  runtime/debug,
 	  runtime/trace,
 	  text/scanner,
 	  text/tabwriter;
@@ -271,8 +270,10 @@
 
 	# executable parsing
 	FMT, encoding/binary, compress/zlib
+	< runtime/debug
 	< debug/dwarf
 	< debug/elf, debug/gosym, debug/macho, debug/pe, debug/plan9obj, internal/xcoff
+	< debug/buildinfo
 	< DEBUG;
 
 	# go parser and friends.
@@ -510,7 +511,7 @@
 	FMT, flag, math/rand
 	< testing/quick;
 
-	FMT, flag, runtime/debug, runtime/trace, internal/sysinfo, math/rand
+	FMT, DEBUG, flag, runtime/trace, internal/sysinfo, math/rand
 	< testing;
 
 	FMT, crypto/sha256, encoding/json, go/ast, go/parser, go/token, math/rand, encoding/hex, crypto/sha256
diff --git a/src/runtime/debug/mod.go b/src/runtime/debug/mod.go
index 11f995b..8c6c480 100644
--- a/src/runtime/debug/mod.go
+++ b/src/runtime/debug/mod.go
@@ -7,7 +7,6 @@
 import (
 	"bytes"
 	"fmt"
-	"strings"
 )
 
 // exported from runtime
@@ -17,11 +16,19 @@
 // in the running binary. The information is available only
 // in binaries built with module support.
 func ReadBuildInfo() (info *BuildInfo, ok bool) {
-	return readBuildInfo(modinfo())
+	data := modinfo()
+	if len(data) < 32 {
+		return nil, false
+	}
+	data = data[16 : len(data)-16]
+	bi := &BuildInfo{}
+	if err := bi.UnmarshalText([]byte(data)); err != nil {
+		return nil, false
+	}
+	return bi, true
 }
 
-// BuildInfo represents the build information read from
-// the running binary.
+// BuildInfo represents the build information read from a Go binary.
 type BuildInfo struct {
 	Path string    // The main package path
 	Main Module    // The module containing the main package
@@ -71,80 +78,85 @@
 	return buf.Bytes(), nil
 }
 
-func readBuildInfo(data string) (*BuildInfo, bool) {
-	if len(data) < 32 {
-		return nil, false
-	}
-	data = data[16 : len(data)-16]
+func (bi *BuildInfo) UnmarshalText(data []byte) (err error) {
+	*bi = BuildInfo{}
+	lineNum := 1
+	defer func() {
+		if err != nil {
+			err = fmt.Errorf("could not parse Go build info: line %d: %w", lineNum, err)
+		}
+	}()
 
-	const (
-		pathLine = "path\t"
-		modLine  = "mod\t"
-		depLine  = "dep\t"
-		repLine  = "=>\t"
+	var (
+		pathLine = []byte("path\t")
+		modLine  = []byte("mod\t")
+		depLine  = []byte("dep\t")
+		repLine  = []byte("=>\t")
+		newline  = []byte("\n")
+		tab      = []byte("\t")
 	)
 
-	readEntryFirstLine := func(elem []string) (Module, bool) {
+	readModuleLine := func(elem [][]byte) (Module, error) {
 		if len(elem) != 2 && len(elem) != 3 {
-			return Module{}, false
+			return Module{}, fmt.Errorf("expected 2 or 3 columns; got %d", len(elem))
 		}
 		sum := ""
 		if len(elem) == 3 {
-			sum = elem[2]
+			sum = string(elem[2])
 		}
 		return Module{
-			Path:    elem[0],
-			Version: elem[1],
+			Path:    string(elem[0]),
+			Version: string(elem[1]),
 			Sum:     sum,
-		}, true
+		}, nil
 	}
 
 	var (
-		info = &BuildInfo{}
 		last *Module
-		line string
+		line []byte
 		ok   bool
 	)
-	// Reverse of cmd/go/internal/modload.PackageBuildInfo
+	// Reverse of BuildInfo.String()
 	for len(data) > 0 {
-		line, data, ok = strings.Cut(data, "\n")
+		line, data, ok = bytes.Cut(data, newline)
 		if !ok {
 			break
 		}
 		switch {
-		case strings.HasPrefix(line, pathLine):
+		case bytes.HasPrefix(line, pathLine):
 			elem := line[len(pathLine):]
-			info.Path = elem
-		case strings.HasPrefix(line, modLine):
-			elem := strings.Split(line[len(modLine):], "\t")
-			last = &info.Main
-			*last, ok = readEntryFirstLine(elem)
-			if !ok {
-				return nil, false
+			bi.Path = string(elem)
+		case bytes.HasPrefix(line, modLine):
+			elem := bytes.Split(line[len(modLine):], tab)
+			last = &bi.Main
+			*last, err = readModuleLine(elem)
+			if err != nil {
+				return err
 			}
-		case strings.HasPrefix(line, depLine):
-			elem := strings.Split(line[len(depLine):], "\t")
+		case bytes.HasPrefix(line, depLine):
+			elem := bytes.Split(line[len(depLine):], tab)
 			last = new(Module)
-			info.Deps = append(info.Deps, last)
-			*last, ok = readEntryFirstLine(elem)
-			if !ok {
-				return nil, false
+			bi.Deps = append(bi.Deps, last)
+			*last, err = readModuleLine(elem)
+			if err != nil {
+				return err
 			}
-		case strings.HasPrefix(line, repLine):
-			elem := strings.Split(line[len(repLine):], "\t")
+		case bytes.HasPrefix(line, repLine):
+			elem := bytes.Split(line[len(repLine):], tab)
 			if len(elem) != 3 {
-				return nil, false
+				return fmt.Errorf("expected 3 columns for replacement; got %d", len(elem))
 			}
 			if last == nil {
-				return nil, false
+				return fmt.Errorf("replacement with no module on previous line")
 			}
 			last.Replace = &Module{
-				Path:    elem[0],
-				Version: elem[1],
-				Sum:     elem[2],
+				Path:    string(elem[0]),
+				Version: string(elem[1]),
+				Sum:     string(elem[2]),
 			}
 			last = nil
 		}
+		lineNum++
 	}
-	return info, true
+	return nil
 }