diff --git a/asm/arm64_goroot_test.go b/asm/arm64_goroot_test.go new file mode 100644 index 0000000..b94f89f --- /dev/null +++ b/asm/arm64_goroot_test.go @@ -0,0 +1,269 @@ +// Copyright (c) 2026 Petr BalvĂ­n (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, and MOVD $sym+off(FP) materialises from ZR", + "runtime/asan_arm64.s": "MOVD $sym+off(FP) materialises MOV from ZR where the toolchain emits ADD $off, SP, Rd", + "runtime/libfuzzer_arm64.s": "MOVD $sym+off(FP) materialises MOV from ZR where the toolchain emits ADD $off, SP, Rd", + "runtime/msan_arm64.s": "MOVD $sym+off(FP) materialises MOV from ZR where the toolchain emits ADD $off, SP, Rd", + "runtime/tls_arm64.s": "a branch distance diverges: an earlier statement's expansion is sized differently", + "runtime/valgrind_arm64.s": "MOVD $sym+off(FP) materialises MOV from ZR where the toolchain emits ADD $off, SP, Rd", + "crypto/internal/fips140/aes/aes_arm64.s": "a tested immediate bit in a TBZ diverges", + "crypto/internal/fips140/aes/gcm/gcm_arm64.s": "an ADD immediate's value diverges mid-function", + "crypto/internal/fips140/nistec/p256_asm_arm64.s": "a branch distance and a logical-immediate lowering diverge", + "runtime/memclr_arm64.s": "a branch distance diverges: an earlier statement's expansion is sized differently", + "runtime/preempt_arm64.s": "the prologue shape for the function's frame diverges at the first word", + "runtime/race_arm64.s": "the flag list and prologue shape diverge at the first words", + "runtime/rt0_linux_arm64.s": "the #ifdef GOOS selection diverges: gasm keeps a word the toolchain drops", + "runtime/sys_linux_arm64.s": "MOVD $sym+off(FP) materialises MOV from ZR where the toolchain emits ADD $off, SP, Rd", + "internal/runtime/syscall/linux/asm_linux_arm64.s": "a branch distance diverges: an earlier statement's expansion is sized differently", + "reflect/asm_arm64.s": "MOVD $sym+off(FP) materialises MOV from ZR where the toolchain emits ADD $off, SP, Rd", +} + +// 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) + } + gt := oracleFuncText(t, mustRead(t, objPath)) + byLocal := gt + + for _, fn := range img.Funcs { + gasmCode := maskCode(append([]byte(nil), img.Code[fn.Offset:fn.Offset+fn.Size]...), fn.Relocs) + goCode, ok := byLocal[fn.Name] + if !ok { + 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(gt), strings.Join(keys, ", ")) + continue + } + goCode = maskCode(append([]byte(nil), goCode...), 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. +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] = 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 +}