test(verify): gate GOOBJ link parity with cmd/link

Assisted-by: GLM 5.3
This commit is contained in:
2026-10-02 00:40:54 +02:00
parent 17cc49fee4
commit dd1782c538
6 changed files with 562 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package verify
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// The GOOBJ link regression: for every supported architecture, a small
// assembly kernel is assembled by the gasm CLI into a GOOBJ object, the
// object is substituted for the toolchain's inside a real `go build`'s
// package archive, and cmd/link is re-run on it. This is the parity
// gate the roadmap demands: what `gasm asm --format goobj` emits must be
// consumed by the toolchain's own linker, not only inspected by hand.
//
// The hard claims, per architecture:
//
// - the re-link with the gasm object succeeds (all four architectures);
// - the linked binary produces exactly the baseline binary's output,
// run natively on amd64 and under qemu-user on arm64 and riscv64
// where the emulator is installed;
// - loong64 is link-only: the run claim is waived for it, because a
// Go runtime coming up under qemu-user is not something this
// architecture's emulator fleet can be assumed to provide.
//
// The archive member is byte-compared against the gasm object after the
// repack, so a build-layout change that silently skipped the substitution
// fails the test instead of passing it vacuously.
// goobjLinkModule is the Go side of the regression module. The assembly
// declares the five functions; main verifies their semantics and prints a
// deterministic line the linked binaries must agree on.
const goobjLinkMainSrc = `package main
func add(a, b int64) int64
func seven() int64
func probe() int64
func entryAddr() uintptr
func counterVal() int64
func main() {
const word = 0x0fedcba987654321
if add(20, 22) != 42 {
panic("add")
}
if probe() != 7+word {
panic("probe")
}
if entryAddr() == 0 {
panic("entry")
}
if counterVal() != 1234 {
panic("counter")
}
println("ok", add(1, 2), probe(), entryAddr() != 0, counterVal())
}
`
// qemuEmulator maps a target GOARCH to its qemu-user program name.
var qemuEmulator = map[string]string{
"amd64": "qemu-x86_64",
"arm64": "qemu-aarch64",
"riscv64": "qemu-riscv64",
}
// goobjLinkRunner returns the command prefix that executes a binary of
// the target architecture on this machine, and whether the run claim
// applies at all. A native host runs natively; otherwise qemu-user is
// required, and loong64 never runs (the runtime does not start under
// qemu-user there, the documented decision).
func goobjLinkRunner(t *testing.T, goarch string) ([]string, bool) {
t.Helper()
if runtime.GOARCH == goarch {
return nil, true
}
if goarch == "loong64" {
return nil, false
}
qemu, err := exec.LookPath(qemuEmulator[goarch])
if err != nil {
return nil, false
}
return []string{qemu}, true
}
// runGoobjBinary executes the linked binary under the given prefix (empty
// for a native run) and returns its combined output.
func runGoobjBinary(prefix []string, path string) ([]byte, error) {
args := append(prefix, path)
cmd := exec.Command(args[0], args[1:]...)
return cmd.CombinedOutput()
}
// fieldAfterLinkLog returns the whitespace-delimited field following the
// first occurrence of flag in line.
func fieldAfterLinkLog(line, flag string) string {
fields := strings.Fields(line)
for i, f := range fields {
if f == flag && i+1 < len(fields) {
return fields[i+1]
}
}
return ""
}
// TestGOOBJLinkRegression is the per-architecture end-to-end gate
// described at the top of this file.
func TestGOOBJLinkRegression(t *testing.T) {
if testing.Short() {
t.Skip("builds the gasm binary and links Go programs")
}
goBin, err := exec.LookPath("go")
if err != nil {
t.Skip("no Go toolchain available")
}
// One gasm binary for every architecture, built from the tree under
// test: the regression exercises the shipped CLI path, not the library.
// The build runs from the module root, one level above this package.
gasmBin := filepath.Join(t.TempDir(), "gasm")
gasmBuild := exec.Command(goBin, "build", "-o", gasmBin, "./cmd/gasm")
gasmBuild.Dir = ".."
if out, err := gasmBuild.CombinedOutput(); err != nil {
t.Fatalf("build gasm: %v\n%s", err, out)
}
for _, goarch := range []string{"amd64", "arm64", "riscv64", "loong64"} {
t.Run(goarch, func(t *testing.T) {
testGOOBJLinkArch(t, goBin, gasmBin, goarch)
})
}
}
func testGOOBJLinkArch(t *testing.T, goBin, gasmBin, goarch string) {
t.Helper()
dir := t.TempDir()
// The module: main.go, go.mod and the kernel as main_<goarch>.s, the
// name the build system selects the assembly by.
kernel, err := os.ReadFile(filepath.Join("testdata", "linkreg_"+goarch+".s"))
if err != nil {
t.Fatalf("read kernel: %v", err)
}
asmPath := filepath.Join(dir, "main_"+goarch+".s")
if err := os.WriteFile(asmPath, kernel, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte(goobjLinkMainSrc), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module linkreg\n\ngo 1.27\n"), 0o644); err != nil {
t.Fatal(err)
}
// The baseline build, with the steps logged so the package archive,
// the assembler's object and the link line can be captured. A fresh
// directory every run keeps the build cache out of the way.
buildEnv := append(os.Environ(), "GOARCH="+goarch)
baseline := filepath.Join(dir, "baseline")
build := exec.Command(goBin, "build", "-x", "-work", "-o", baseline, ".")
build.Dir = dir
build.Env = buildEnv
buildLog, err := build.CombinedOutput()
if err != nil {
t.Fatalf("baseline build: %v\n%s", err, buildLog)
}
var work, asmObj, pkgArch, linkLine string
for line := range strings.SplitSeq(string(buildLog), "\n") {
switch {
case strings.HasPrefix(line, "WORK="):
work = strings.TrimPrefix(line, "WORK=")
case strings.Contains(line, "/asm ") && strings.Contains(line, "main_"+goarch+".s") && !strings.Contains(line, "-gensymabis"):
asmObj = fieldAfterLinkLog(line, "-o")
case strings.Contains(line, "pack r") && strings.Contains(line, "_pkg_.a"):
rest := strings.TrimSpace(strings.SplitN(line, "pack r", 2)[1])
pkgArch = strings.Fields(strings.SplitN(rest, "#", 2)[0])[0]
case strings.Contains(line, "/link ") && strings.Contains(line, "-importcfg"):
linkLine = line
}
}
if work == "" || asmObj == "" || pkgArch == "" || linkLine == "" {
t.Fatalf("could not locate the build steps in the -x log (work=%q asmObj=%q pkgArch=%q link=%v):\n%s",
work, asmObj, pkgArch, linkLine, buildLog)
}
defer os.RemoveAll(work)
asmObj = strings.ReplaceAll(asmObj, "$WORK", work)
pkgArch = strings.ReplaceAll(pkgArch, "$WORK", work)
member := filepath.Base(asmObj)
// The reference output, before any substitution: the run claim
// compares against what the toolchain's own object produces.
prefix, canRun := goobjLinkRunner(t, goarch)
var baseOut []byte
if canRun {
var runErr error
baseOut, runErr = runGoobjBinary(prefix, baseline)
if runErr != nil {
// The environment cannot execute this architecture at all
// (an emulator that cannot host the runtime): the run claim
// degrades, the link claim below still applies in full.
t.Logf("baseline binary does not run here (%v); degrading to link-only", runErr)
canRun = false
}
}
// Assemble the same source with the gasm CLI.
gasmObjPath := filepath.Join(dir, "gasm.o")
asmCmd := exec.Command(gasmBin, "asm", "--format", "goobj", "-p", "main",
"-GOARCH", goarch, "-o", gasmObjPath, asmPath)
if out, err := asmCmd.CombinedOutput(); err != nil {
t.Fatalf("gasm asm --format goobj: %v\n%s", err, out)
}
gasmObj, err := os.ReadFile(gasmObjPath)
if err != nil {
t.Fatal(err)
}
// Rebuild the package archive with the gasm object in place of the
// toolchain's: extract, substitute, repack.
members := filepath.Join(dir, "members")
if err := os.MkdirAll(members, 0o755); err != nil {
t.Fatal(err)
}
extract := exec.Command(goBin, "tool", "pack", "x", pkgArch)
extract.Dir = members
extract.Env = buildEnv
if out, err := extract.CombinedOutput(); err != nil {
t.Fatalf("pack x: %v\n%s", err, out)
}
memberPath := filepath.Join(members, member)
if _, err := os.Stat(memberPath); err != nil {
t.Fatalf("the assembler's archive member %s was not extracted: %v", member, err)
}
if err := os.Chmod(memberPath, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(memberPath, gasmObj, 0o644); err != nil {
t.Fatal(err)
}
listCmd := exec.Command(goBin, "tool", "pack", "t", pkgArch)
listCmd.Env = buildEnv
listOut, err := listCmd.CombinedOutput()
if err != nil {
t.Fatalf("pack t: %v\n%s", err, listOut)
}
newArch := filepath.Join(dir, "pkg.a")
packArgs := []string{"tool", "pack", "c", newArch}
seen := map[string]bool{}
for m := range strings.FieldsSeq(string(listOut)) {
if seen[m] {
continue
}
seen[m] = true
if err := os.Chmod(filepath.Join(members, m), 0o644); err != nil {
t.Fatal(err)
}
packArgs = append(packArgs, m)
}
pack := exec.Command(goBin, packArgs...)
pack.Dir = members
pack.Env = buildEnv
if out, err := pack.CombinedOutput(); err != nil {
t.Fatalf("pack c: %v\n%s", err, out)
}
// Prove the substitution: the archive handed to the linker holds the
// gasm object, byte for byte.
checkDir := filepath.Join(dir, "check")
if err := os.MkdirAll(checkDir, 0o755); err != nil {
t.Fatal(err)
}
check := exec.Command(goBin, "tool", "pack", "x", newArch)
check.Dir = checkDir
check.Env = buildEnv
if out, err := check.CombinedOutput(); err != nil {
t.Fatalf("pack x (verification): %v\n%s", err, out)
}
got, err := os.ReadFile(filepath.Join(checkDir, member))
if err != nil {
t.Fatalf("read the substituted member back: %v", err)
}
if !bytes.Equal(got, gasmObj) {
t.Fatal("the repacked archive does not carry the gasm object")
}
// Re-link with the substituted archive. The line carries the
// GOROOT assignment and $WORK placeholders; GOARCH selects the
// linker's target configuration and GOEXPERIMENT the header it
// expects in the objects.
goExp, _ := exec.Command(goBin, "env", "GOEXPERIMENT").Output()
link := strings.ReplaceAll(linkLine, "$WORK", work)
link = strings.ReplaceAll(link, filepath.Join(work, "b001", "exe", "a.out"), filepath.Join(dir, "gasm-linked"))
link = strings.ReplaceAll(link, filepath.Join(work, "b001", "_pkg_.a"), newArch)
linkCmd := exec.Command("sh", "-c", "cd "+dir+" && "+link)
linkCmd.Env = append(buildEnv, "GOEXPERIMENT="+strings.TrimSpace(string(goExp)))
linkOut, err := linkCmd.CombinedOutput()
if err != nil {
t.Fatalf("link with the gasm object: %v\n%s", err, linkOut)
}
// The run claim: identical output to the baseline.
if !canRun {
if runtime.GOARCH == goarch {
t.Fatalf("the native run claim is mandatory on %s hosts", goarch)
}
if goarch == "loong64" {
t.Skipf("loong64 is link-only: a Go runtime under qemu-user is not assumed for it")
}
t.Skipf("%s is not installed and no %s host: the run claim degrades to link-only",
qemuEmulator[goarch], goarch)
}
gasmOut, err := runGoobjBinary(prefix, filepath.Join(dir, "gasm-linked"))
if err != nil {
t.Fatalf("the gasm-linked binary failed: %v\n%s", err, gasmOut)
}
if !bytes.Equal(gasmOut, baseOut) {
t.Errorf("gasm-linked output %q, want the baseline %q", gasmOut, baseOut)
}
}