Files
gasm-sdk/asm/arm64_goroot_test.go
T
2026-10-07 21:34:30 +02:00

270 lines
9.9 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package asm
import (
"bytes"
"encoding/binary"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/gasm-sdk/parser"
)
var le = binary.LittleEndian
// kindSTEXTFIPS is objabi's STEXTFIPS: the fips140 packages' text kind.
const kindSTEXTFIPS = 2
// gorootARM64Packages names the GOROOT packages whose arm64 assembly the
// parity harness pins: every *_arm64.s of each package, as the real build
// assembles it, the package's generated go_asm.h included. Together they
// carry the heavy real-world shapes: the runtime's TLS and stack plumbing,
// the cryptographic kernels, big-number arithmetic and the bytealg search
// loops.
var gorootARM64Packages = []string{
"runtime",
"internal/bytealg",
"internal/cpu",
"internal/chacha8rand",
"internal/runtime/maps",
"reflect",
"math/big",
"hash/crc32",
"crypto/md5",
"crypto/sha1",
"crypto/internal/fips140/aes",
"crypto/internal/fips140/aes/gcm",
"crypto/internal/fips140/bigmod",
"crypto/internal/fips140/nistec",
"crypto/internal/fips140/sha256",
"crypto/internal/fips140/sha512",
"crypto/internal/fips140/sha3",
"crypto/internal/fips140/subtle",
}
// gorootARM64GapFiles names the files kept out of the byte parity set by
// known, pre-existing gaps, each with the reason. A file here is skipped,
// not silently dropped: the gaps are findings, and closing one is a matter
// of removing its entry and watching the file pin itself.
var gorootARM64GapFiles = map[string]string{
"runtime/asm_arm64.s": "the unparenthesised NOSPLIT|NOFRAME flag list is not recognised, so the prologue and guard shapes diverge",
"runtime/sys_linux_arm64.s": "the unparenthesised NOSPLIT|NOFRAME flag list is not recognised, so the prologue and guard shapes diverge (cgoSigtramp, clone)",
"runtime/preempt_arm64.s": "the unparenthesised NOSPLIT|NOFRAME flag list is not recognised, so the prologue and guard shapes diverge (asyncPreempt)",
"runtime/race_arm64.s": "the unparenthesised NOSPLIT|NOFRAME flag list is not recognised, so the prologue shape diverges (racecallbackthunk)",
"runtime/rt0_linux_arm64.s": "the #ifdef GOOS selection diverges: gasm keeps a word the toolchain drops",
}
// gorootOtherGOOS matches the file names of the ports the linux build never
// assembles: the harness pins the linux arm64 set.
var gorootOtherGOOS = regexp.MustCompile(`_(darwin|ios|freebsd|netbsd|openbsd|windows|android|plan9|aix|js|wasip1)_`)
// TestGOROOTARM64Parity assembles each package's arm64 files with gasm and
// with the installed toolchain and holds the functions' bytes equal,
// relocation sites masked. The toolchain side needs the go_asm.h the build
// generates for the package, so the harness rebuilds it once with -work and
// harvests the header the compiler wrote; the -gcflags flag exists only to
// make that rebuild happen, the header's constants do not depend on it.
func TestGOROOTARM64Parity(t *testing.T) {
if testing.Short() {
t.Skip("live go tool asm oracle and per-package rebuild: skipped in -short mode")
}
goBin, err := exec.LookPath("go")
if err != nil {
t.Skip("no Go toolchain available")
}
out, err := exec.Command(goBin, "env", "GOROOT").Output()
if err != nil {
t.Fatalf("go env GOROOT: %v", err)
}
goroot := strings.TrimSpace(string(out))
include := filepath.Join(goroot, "pkg", "include")
totalFns, totalBytes := 0, 0
for _, pkg := range gorootARM64Packages {
t.Run(pkg, func(t *testing.T) {
dir := t.TempDir()
work := harvestGoAsm(t, goBin, pkg)
defer os.RemoveAll(work)
headers, err := filepath.Glob(filepath.Join(work, "b*", "go_asm.h"))
if err != nil || len(headers) == 0 {
t.Fatalf("no generated go_asm.h under %s", work)
}
if err := os.WriteFile(filepath.Join(dir, "go_asm.h"), mustRead(t, headers[0]), 0o644); err != nil {
t.Fatal(err)
}
files, err := filepath.Glob(filepath.Join(goroot, "src", pkg, "*_arm64.s"))
if err != nil || len(files) == 0 {
t.Fatalf("no arm64 assembly found for %s", pkg)
}
for _, f := range files {
base := filepath.Base(f)
if gorootOtherGOOS.MatchString(base) {
continue // another port's file: the linux build never assembles it
}
t.Run(base, func(t *testing.T) {
if reason, gap := gorootARM64GapFiles[pkg+"/"+base]; gap {
t.Skip(reason)
}
// The parser side: the file's own directory resolves the
// package's headers, the harvested directory carries
// go_asm.h, and the platform conditionals read the same
// predefines the go command drives go tool asm with.
src := string(mustRead(t, f))
af, errs := parser.ParseWithOptions(f, src, parser.Options{
Expand: true,
IncludeDirs: []string{dir, filepath.Join(goroot, "src", pkg), include},
Predefines: map[string]string{
"GOARCH_arm64": "1",
"GOOS_linux": "1",
},
})
if len(errs) > 0 {
t.Fatalf("parse: %v", errs[0])
}
img, err := AssembleFileARM64(af)
if err != nil {
t.Fatalf("AssembleFileARM64: %v", err)
}
// The oracle side: the build's own invocation, the
// generated header directory first.
objPath := filepath.Join(t.TempDir(), "oracle.o")
cmd := exec.Command(goBin, "tool", "asm",
"-I", dir, "-I", filepath.Join(goroot, "src", pkg), "-I", include,
"-D", "GOOS_linux", "-D", "GOARCH_arm64", "-std",
"-p", pkg, "-o", objPath, f)
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64")
if oout, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("go tool asm %s: %v\n%s", base, err, oout)
}
byLocal := oracleFuncText(t, mustRead(t, objPath))
// The object's symdef order is the source order, gasm's
// function list too, so same-named functions pair up in
// definition order: a file-local kernel beside its
// package-level twin (runtime·racefuncenter and
// racefuncenter<>) carries the plain name twice in the
// object and the reader cannot see the locality.
seen := map[string]int{}
for _, fn := range img.Funcs {
gasmCode := maskCode(append([]byte(nil), img.Code[fn.Offset:fn.Offset+fn.Size]...), fn.Relocs)
bodies := byLocal[fn.Name]
idx := seen[fn.Name]
seen[fn.Name] = idx + 1
if idx >= len(bodies) {
keys := make([]string, 0, len(byLocal))
for name := range byLocal {
keys = append(keys, name)
}
t.Errorf("%s: not in the oracle output (%d functions: %s)",
fn.Name, len(byLocal), strings.Join(keys, ", "))
continue
}
goCode := maskCode(append([]byte(nil), bodies[idx]...), fn.Relocs)
cmpLen := min(len(goCode), len(gasmCode))
if !bytes.Equal(gasmCode[:cmpLen], goCode[:cmpLen]) {
for w := 0; w < cmpLen/4; w++ {
g := le.Uint32(gasmCode[w*4:])
o := le.Uint32(goCode[w*4:])
if g != o {
t.Errorf("%s: word %d (offset %d) differs: gasm %08x go %08x", fn.Name, w, w*4, g, o)
break
}
}
continue
}
if len(goCode) > len(gasmCode) {
for _, b := range goCode[len(gasmCode):] {
if b != 0 {
t.Errorf("%s: non-zero trailing bytes in the oracle output", fn.Name)
break
}
}
}
totalFns++
totalBytes += len(gasmCode)
}
})
}
})
}
t.Logf("GOROOT arm64 parity: %d functions, %d bytes identical", totalFns, totalBytes)
}
// oracleFuncText extracts every TEXT function of a toolchain object, the
// non-package and the hashed (file-local) definitions both, keyed by the
// local name: GOROOT keeps several kernels file-local (cmpbody<>,
// encryptBlockAsm<>), and those ride the hashed definition blocks the
// non-package reader never sees. The value is the functions' bodies in
// symdef order: a file-local kernel beside its package-level twin carries
// the same plain name twice (the object reader cannot see the locality),
// and the encoder pairs them up in definition order.
func oracleFuncText(t *testing.T, obj []byte) map[string][][]byte {
t.Helper()
v := openGoobj(t, obj)
data := v.blk(blkData)
didx := v.blk(blkDataIdx)
out := make(map[string][][]byte)
di := 0
for _, bi := range []int{blkSymdef, blkHashed64def, blkHasheddef, blkNonpkgdef} {
for _, s := range v.syms(bi) {
// STEXT and STEXTFIPS both: the fips140 packages' text carries
// the FIPS kind in Go 1.27 and up.
if (s.typ == kindSTEXT || s.typ == kindSTEXTFIPS) && s.size > 0 && 4*di+8 <= len(didx) {
off := le.Uint32(didx[4*di:])
if int(off)+int(s.size) <= len(data) {
name := s.name
if _, after, ok := strings.Cut(name, "."); ok {
name = after
}
out[name] = append(out[name], data[off:int(off)+int(s.size)])
}
}
di++
}
}
return out
}
// harvestGoAsm rebuilds pkg once with -work and returns the work directory
// holding the compiler's generated go_asm.h. A fully cached build leaves
// the work directory empty, so the compile action is given a unique, inert
// flag value each run (the inlining level never touches the header's
// constants) and re-runs for the target package alone.
func harvestGoAsm(t *testing.T, goBin, pkg string) string {
t.Helper()
nonce := time.Now().UnixNano() % 1000000
cmd := exec.Command(goBin, "build", "-x", "-work",
"-gcflags", pkg+"=-N", "-gcflags", pkg+"=-l=7"+strconv.FormatInt(nonce, 10),
"-o", "/dev/null", pkg)
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=arm64")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("rebuild %s: %v\n%s", pkg, err, out)
}
m := regexp.MustCompile(`WORK=(\S+)`).FindSubmatch(out)
if m == nil {
t.Fatalf("rebuild %s: no WORK directory in the build log", pkg)
}
return string(m[1])
}
// mustRead reads path, failing the test when it cannot.
func mustRead(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return data
}