// Copyright (c) 2026 Petr BalvĂ­n (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. It is a deliberate verification, // not a suite member: building the gasm binary and cross-linking a // module per architecture is minutes of work the shared one-core runner // beside the forge cannot afford on every push, so it runs opt-in, the // justfile's link-parity recipe being the intended way. func TestGOOBJLinkRegression(t *testing.T) { if testing.Short() { t.Skip("builds the gasm binary and links Go programs") } if os.Getenv("GASM_LINK_PARITY") == "" { t.Skip("deliberate verification: set GASM_LINK_PARITY=1 (just link-parity)") } 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_.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) } }