2026-10-07 19:50:24 +02:00
// 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 {
2026-10-07 21:22:00 +02:00
"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" ,
2026-10-07 19:50:24 +02:00
}
// 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 ]), 0 o644 ); 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 )
}
2026-10-07 21:22:00 +02:00
byLocal := oracleFuncText ( t , mustRead ( t , objPath ))
2026-10-07 19:50:24 +02:00
2026-10-07 21:22:00 +02:00
// 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 {}
2026-10-07 19:50:24 +02:00
for _ , fn := range img . Funcs {
gasmCode := maskCode ( append ([] byte ( nil ), img . Code [ fn . Offset : fn . Offset + fn . Size ] ... ), fn . Relocs )
2026-10-07 21:22:00 +02:00
bodies := byLocal [ fn . Name ]
idx := seen [ fn . Name ]
seen [ fn . Name ] = idx + 1
if idx >= len ( bodies ) {
2026-10-07 19:50:24 +02:00
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)" ,
2026-10-07 21:22:00 +02:00
fn . Name , len ( byLocal ), strings . Join ( keys , ", " ))
2026-10-07 19:50:24 +02:00
continue
}
2026-10-07 21:22:00 +02:00
goCode := maskCode ( append ([] byte ( nil ), bodies [ idx ] ... ), fn . Relocs )
2026-10-07 19:50:24 +02:00
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
2026-10-07 21:22:00 +02:00
// 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 {
2026-10-07 19:50:24 +02:00
t . Helper ()
v := openGoobj ( t , obj )
data := v . blk ( blkData )
didx := v . blk ( blkDataIdx )
2026-10-07 21:22:00 +02:00
out := make ( map [ string ][][] byte )
2026-10-07 19:50:24 +02:00
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
}
2026-10-07 21:22:00 +02:00
out [ name ] = append ( out [ name ], data [ off : int ( off ) + int ( s . size )])
2026-10-07 19:50:24 +02:00
}
}
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
}