cmd/go: use the resolved buildmode in build IDs and build info

When -buildmode=default is used, the link action ID hashed the
literal string "default", so building with -buildmode=default and
building with the buildmode it resolves to produced binaries that
were identical except for their build IDs. Hash the resolved
buildmode (ldBuildmode) instead, so that equivalent builds share a
build ID and cached link outputs.

Additionally, the build info stamped into binaries normalized
-buildmode=default to "exe" without accounting for platforms where
the default is PIE (android, ios, darwin, and windows without
-race), misreporting the buildmode actually given to the linker.
Record "pie" on those platforms instead.

Together these make it possible to reproduce a binary built with
-buildmode=default from its stamped build info alone.

Fixes #63559

Change-Id: If3ac6a6a84fbf20097ff3009e228ce07705d6835
GitHub-Last-Rev: 1ddf5bfef00c64cc589d90e4e8bf4257f8d05b88
GitHub-Pull-Request: golang/go#80312
Reviewed-on: https://go-review.googlesource.com/c/go/+/798680
Reviewed-by: Mark Freeman <markfreeman@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Sean Liao <sean@liao.dev>
Reviewed-by: Michael Pratt <mpratt@google.com>
diff --git a/src/cmd/go/internal/load/pkg.go b/src/cmd/go/internal/load/pkg.go
index 6c1e51e..7061630 100644
--- a/src/cmd/go/internal/load/pkg.go
+++ b/src/cmd/go/internal/load/pkg.go
@@ -2443,6 +2443,9 @@
 	if buildmode == "default" {
 		if p.Name == "main" {
 			buildmode = "exe"
+			if platform.DefaultPIE(cfg.Goos, cfg.Goarch, cfg.BuildRace) {
+				buildmode = "pie"
+			}
 		} else {
 			buildmode = "archive"
 		}
diff --git a/src/cmd/go/internal/work/exec.go b/src/cmd/go/internal/work/exec.go
index db3b7bf..41d1673 100644
--- a/src/cmd/go/internal/work/exec.go
+++ b/src/cmd/go/internal/work/exec.go
@@ -1669,7 +1669,10 @@
 
 	// Toolchain-independent configuration.
 	fmt.Fprintf(h, "link\n")
-	fmt.Fprintf(h, "buildmode %s goos %s goarch %s\n", cfg.BuildBuildmode, cfg.Goos, cfg.Goarch)
+	// Hash the resolved buildmode (ldBuildmode), not cfg.BuildBuildmode,
+	// so that -buildmode=default produces the same build ID as the
+	// buildmode it resolves to. See go.dev/issue/63559.
+	fmt.Fprintf(h, "buildmode %s goos %s goarch %s\n", ldBuildmode, cfg.Goos, cfg.Goarch)
 	fmt.Fprintf(h, "import %q\n", p.ImportPath)
 	fmt.Fprintf(h, "omitdebug %v standard %v local %v prefix %q\n", p.Internal.OmitDebug, p.Standard, p.Internal.Local, p.Internal.LocalPrefix)
 	fmt.Fprintf(h, "defaultgodebug %q\n", p.DefaultGODEBUG)
diff --git a/src/cmd/go/scriptconds_test.go b/src/cmd/go/scriptconds_test.go
index 25053dd..277352d 100644
--- a/src/cmd/go/scriptconds_test.go
+++ b/src/cmd/go/scriptconds_test.go
@@ -11,6 +11,7 @@
 	"errors"
 	"fmt"
 	"internal/buildcfg"
+	"internal/platform"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -45,6 +46,7 @@
 	add("git-sha256", script.OnceCondition("the local 'git' version is recent enough to support sha256 object/commit hashes", gitSupportsSHA256))
 	add("trimpath", script.OnceCondition("test binary was built with -trimpath", isTrimpath))
 	add("default-cgo", lazyBool("when CGO_ENABLED=1|0 was set in make.bash", defaultCgo))
+	add("default-pie", script.Condition("-buildmode=default resolves to -buildmode=pie", defaultPIE))
 
 	return conds
 }
@@ -149,3 +151,14 @@
 func defaultCgo() bool {
 	return buildcfg.DefaultCGO_ENABLED == "1" || buildcfg.DefaultCGO_ENABLED == "0"
 }
+
+// defaultPIE reports whether -buildmode=default resolves to -buildmode=pie for
+// the script's GOOS/GOARCH. It assumes -race is not in effect, which is the only
+// case where the resolved default buildmode depends on the race flag (PIE is not
+// the default with -race on windows). Scripts that build with -race must not rely
+// on this condition.
+func defaultPIE(s *script.State) (bool, error) {
+	GOOS, _ := s.LookupEnv("GOOS")
+	GOARCH, _ := s.LookupEnv("GOARCH")
+	return platform.DefaultPIE(GOOS, GOARCH, false), nil
+}
diff --git a/src/cmd/go/testdata/script/README b/src/cmd/go/testdata/script/README
index 7105215..cd04842 100644
--- a/src/cmd/go/testdata/script/README
+++ b/src/cmd/go/testdata/script/README
@@ -391,6 +391,8 @@
 	cmd/go GOOS/GOARCH != GOHOSTOS/GOHOSTARCH
 [default-cgo]
 	when CGO_ENABLED=1|0 was set in make.bash
+[default-pie]
+	-buildmode=default resolves to -buildmode=pie
 [exec:*]
 	<suffix> names an executable in the test binary's PATH
 [fuzz]
diff --git a/src/cmd/go/testdata/script/build_buildmode_default_reproducible.txt b/src/cmd/go/testdata/script/build_buildmode_default_reproducible.txt
new file mode 100644
index 0000000..a841e5c
--- /dev/null
+++ b/src/cmd/go/testdata/script/build_buildmode_default_reproducible.txt
@@ -0,0 +1,37 @@
+# Issue 63559: building with -buildmode=default should produce
+# a binary identical to one built with the buildmode that default
+# resolves to, and the build info stamped into the binary should
+# record the resolved buildmode.
+
+[short] skip 'links binaries'
+# gccgo does not set -fPIE for the default->pie case (unlike explicit
+# -buildmode=pie), so the two binaries would not be identical there.
+[compiler:gccgo] skip
+
+# Build with -buildmode=default and, separately, with the buildmode that
+# default resolves to. Use distinct build caches so that the second build
+# cannot be served from the first build's link cache: the binaries must be
+# byte-identical because they are truly reproducible, not because one was
+# copied from the other.
+env GOCACHE=$WORK/cache-default
+go build -buildmode=default -o default$GOEXE .
+
+env GOCACHE=$WORK/cache-explicit
+[!default-pie] go build -buildmode=exe -o explicit$GOEXE .
+[default-pie] go build -buildmode=pie -o explicit$GOEXE .
+
+cmp -q default$GOEXE explicit$GOEXE
+
+# The build info must record the resolved buildmode, not the literal "default".
+go version -m default$GOEXE
+[!default-pie] stdout -buildmode=exe
+[default-pie] stdout -buildmode=pie
+
+-- go.mod --
+module m
+
+go 1.26
+-- main.go --
+package main
+
+func main() {}
diff --git a/src/cmd/go/testdata/script/version.txt b/src/cmd/go/testdata/script/version.txt
index 722859f..5958dca 100644
--- a/src/cmd/go/testdata/script/version.txt
+++ b/src/cmd/go/testdata/script/version.txt
@@ -47,14 +47,16 @@
 go version fortune.exe
 stdout '^fortune.exe: .+'
 go version -m fortune.exe
-stdout -buildmode=exe
+[!default-pie] stdout -buildmode=exe
+[default-pie] stdout -buildmode=pie
 stdout '^\tpath\trsc.io/fortune'
 stdout '^\tmod\trsc.io/fortune\tv1.0.0'
 
 # Check the build info of a binary built from $GOROOT/src/cmd
 go build -o test2json.exe cmd/test2json
 go version -m test2json.exe
-stdout -buildmode=exe
+[!default-pie] stdout -buildmode=exe
+[default-pie] stdout -buildmode=pie
 stdout '^test2json.exe: .+'
 stdout '^\tpath\tcmd/test2json$'
 ! stdout 'mod[^e]'