270 lines
9.9 KiB
Go
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
|
|
}
|