diff --git a/verify/abi.go b/verify/abi.go index 0cd9d59..fa65fa8 100644 --- a/verify/abi.go +++ b/verify/abi.go @@ -16,21 +16,51 @@ import ( // canary below SP, returning both the argument block (with results) and an // ABIReport. // +// The callee is assumed to declare no local frame ($0 in its TEXT +// directive); use CallCheckedFrame, or Kernel.CallFuncChecked which reads +// the frame from the image, for a callee with a frame. A callee whose +// frame extends past the fixed call margin would otherwise trip the canary +// with perfectly legal writes. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. +// // The architecture-specific parts live in abi_.s: enterJITChecked // plants sentinels in the registers the Go ABI fixes across calls (the // frame pointer and the goroutine pointer) before switching to the // prepared stack, and the raw return trampoline leaveJITCheckedRaw // compares them and records violations in abiResult. func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) { + return CallCheckedFrame(fnAddr, args, 0) +} + +// CallCheckedFrame is CallChecked with the canary gap sized for a callee +// that declares a local frame of frame bytes: the canary window is placed +// below the deepest write a legal kernel may make, its own frame plus the +// call margin, so only writes that go below the declared frame trip it. +// Callers that already hold the function layout pass asm.FuncLayout.Frame. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. +func CallCheckedFrame(fnAddr uintptr, args []byte, frame int) ([]byte, ABIReport, error) { report := ABIReport{} // Reset the global result. abiResult = 0 - // Prepare the stack: [canary][padding][leaveJITCheckedRaw][args...] - // The canary sits below the initial SP, so the function would have to - // write below SP to corrupt it. - totalSize := redZoneSize + stackPad + 8 + len(args) + 64 + // The gap between the canary window and the entry stack pointer must + // cover every write a legal kernel makes. Two parts: + // - frame: the callee's declared local frame, which the ABI lets it + // write anywhere in [SP-frame, SP). + // - stackPad (64): the call margin. A kernel may CALL another + // function, which pushes a return address below the frame and runs + // a small prologue of its own; 64 bytes covers both. Callees' + // own frames are not accounted: a kernel calling deep into other + // frames can write below this gap without detection. + pad := stackPad + frame + + // Prepare the stack: [canary][frame gap][leaveJITCheckedRaw][args...] + totalSize := redZoneSize + pad + 8 + len(args) + 64 stackMem, err := syscall.Mmap(-1, 0, totalSize, syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_PRIVATE|syscall.MAP_ANON) if err != nil { @@ -43,8 +73,8 @@ func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) { stackMem[i] = redZoneFill } - // Return address and args after the canary and padding. - retOff := redZoneSize + stackPad + // Return address and args after the canary and the frame gap. + retOff := redZoneSize + pad binary.LittleEndian.PutUint64(stackMem[retOff:retOff+8], uint64(leaveCheckedPtr)) copy(stackMem[retOff+8:], args) diff --git a/verify/abi_arm64.s b/verify/abi_arm64.s index e3bdfd1..c75080b 100644 --- a/verify/abi_arm64.s +++ b/verify/abi_arm64.s @@ -36,7 +36,8 @@ DATA ·leaveCheckedPtr(SB)/8, $·leaveJITCheckedRaw(SB) TEXT ·enterJITChecked(SB), NOSPLIT, $0-16 MOVD fn+0(FP), R0 // target (before SP switch) MOVD R30, savedLR(SB) // save link register - MOVD R3, savedSP(SB) // save Go stack pointer + MOVD RSP, R3 // save Go stack pointer: R3 relays the value, the + MOVD R3, savedSP(SB) // arm64 assembler cannot store RSP to memory MOVD R29, savedFP(SB) // save frame pointer (vet requires save before clobber) MOVD g, savedG(SB) // save g MOVD $SENTINEL_FP, R29 // sentinel in the frame pointer @@ -85,8 +86,9 @@ restore: // Package-level storage for the saved frame pointer. Like savedSP and // savedLR in trampoline_arm64.s, this is assembly-side state: the amd64 -// checked trampoline saves the caller's frame pointer for vet's sake and -// never restores it, and this file mirrors that. +// checked trampoline saves and restores the caller's frame pointer too +// (leaveJITCheckedRaw restores BP before returning), and this file mirrors +// that. GLOBL savedFP(SB), NOPTR, $8 GLOBL savedG(SB), NOPTR, $8 diff --git a/verify/abi_loong64.s b/verify/abi_loong64.s index b57a51e..f3994e4 100644 --- a/verify/abi_loong64.s +++ b/verify/abi_loong64.s @@ -64,3 +64,14 @@ g_ok: JMP (R1) // return to Go caller GLOBL savedG(SB), NOPTR, $8 + +// leaveRawAddr holds the raw .abi0 address of leaveJIT, read by +// call_loong64.go in preference to reflect.ValueOf(leaveJIT), which returns +// the address of the ABIInternal wrapper the linker interposes: the +// wrapper's prologue clobbers the saved-register window the JIT call +// depends on. It lives here rather than beside leaveJIT in +// trampoline_loong64.s because that file is assembled by gasm itself in +// the ground-truth byte tests, and gasm cannot yet encode a symbol-valued +// DATA word. +GLOBL ·leaveRawAddr(SB), NOPTR, $8 +DATA ·leaveRawAddr(SB)/8, $·leaveJIT(SB) diff --git a/verify/abi_other.go b/verify/abi_other.go index f61585e..2d0ae82 100644 --- a/verify/abi_other.go +++ b/verify/abi_other.go @@ -11,3 +11,8 @@ import "fmt" func CallChecked(fnAddr uintptr, args []byte) ([]byte, ABIReport, error) { return nil, ABIReport{}, fmt.Errorf("verify: ABI checks are not supported on this architecture") } + +// CallCheckedFrame is unavailable on unsupported architectures. +func CallCheckedFrame(fnAddr uintptr, args []byte, frame int) ([]byte, ABIReport, error) { + return nil, ABIReport{}, fmt.Errorf("verify: ABI checks are not supported on this architecture") +} diff --git a/verify/abi_report.go b/verify/abi_report.go index ccf57ff..971da6f 100644 --- a/verify/abi_report.go +++ b/verify/abi_report.go @@ -47,7 +47,9 @@ func (r ABIReport) String() string { // redZoneSize is the canary window below the prepared stack pointer. On // amd64 it is the System V red zone; on every architecture a Go function -// must not write below SP, so any corruption there is a bug. +// must not write below its own declared frame, so the canary sits below +// the frame plus the call margin (see CallCheckedFrame) and any corruption +// there is a bug. const redZoneSize = 128 // redZoneFill is the byte pattern used to detect writes below SP. diff --git a/verify/abi_test.go b/verify/abi_test.go index 8a5c9e7..bf50e71 100644 --- a/verify/abi_test.go +++ b/verify/abi_test.go @@ -4,11 +4,13 @@ package verify import ( + "os" "testing" ) func loadABIKernel(t *testing.T) *Kernel { t.Helper() + requireHost(t, "amd64") k, err := Load("../testdata/verify/abi_amd64.s") if err != nil { t.Fatalf("Load: %v", err) @@ -93,3 +95,85 @@ func TestCallFuncCheckedErrors(t *testing.T) { t.Fatal("expected error for too-small arg block") } } + +// loadCanaryKernel writes an inline kernel pair that exercises the canary +// geometry against declared frames: frameLocal owns a $96 local frame and +// writes its lowest local (96 bytes below the entry stack pointer, well +// past the 64-byte call margin a frame-0 kernel gets); belowFrame writes +// 136 bytes below its own frame, deep into the canary window. +func loadCanaryKernel(t *testing.T) *Kernel { + t.Helper() + requireHost(t, "amd64") + src := `#include "textflag.h" + +// func frameLocal(x int64) int64 +TEXT ·frameLocal(SB), NOSPLIT, $96-16 + MOVQ x+0(FP), AX + MOVQ AX, l-96(SP) + MOVQ l-96(SP), AX + MOVQ AX, ret+8(FP) + RET + +// func belowFrame(x int64) int64 +TEXT ·belowFrame(SB), NOSPLIT, $96-16 + MOVQ $1, -136(SP) + MOVQ x+0(FP), AX + MOVQ AX, ret+8(FP) + RET +` + file := t.TempDir() + "/canary_amd64.s" + if err := os.WriteFile(file, []byte(src), 0o644); err != nil { + t.Fatalf("write kernel: %v", err) + } + k, err := Load(file) + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + return k +} + +// TestCallCheckedFrameLegal checks that a kernel whose declared frame +// extends below the fixed 64-byte margin does not trip the canary: the +// protected gap must adapt to the frame the TEXT directive declares. +func TestCallCheckedFrameLegal(t *testing.T) { + k := loadCanaryKernel(t) + + args := make([]byte, 16) + PutUint64(args, 0, 42) + + out, report, err := k.CallFuncChecked("frameLocal", args) + if err != nil { + t.Fatalf("CallFuncChecked(frameLocal): %v", err) + } + if got := int64(GetUint64(out, 8)); got != 42 { + t.Errorf("frameLocal(42) = %d, want 42", got) + } + if report.RedZoneHit { + t.Error("frameLocal: writing its own $96 frame must not count as a red-zone hit") + } + if !report.OK() { + t.Errorf("frameLocal: %s", report) + } +} + +// TestCallCheckedBelowFrameCaught checks the other side of the adaptive +// gap: a kernel that writes below its own frame by more than the call +// margin must still be reported. +func TestCallCheckedBelowFrameCaught(t *testing.T) { + k := loadCanaryKernel(t) + + args := make([]byte, 16) + PutUint64(args, 0, 42) + + out, report, err := k.CallFuncChecked("belowFrame", args) + if err != nil { + t.Fatalf("CallFuncChecked(belowFrame): %v", err) + } + if got := int64(GetUint64(out, 8)); got != 42 { + t.Errorf("belowFrame(42) = %d, want 42", got) + } + if !report.RedZoneHit { + t.Error("belowFrame: expected RedZoneHit for a write below the declared frame") + } +} diff --git a/verify/buffers.go b/verify/buffers.go index af6b7b4..2ee6ce2 100644 --- a/verify/buffers.go +++ b/verify/buffers.go @@ -20,7 +20,11 @@ type BufSpec struct { } // ParseBufSpec parses a "name:size:pattern[,name:size:pattern]" spec string -// into individual buffer specs. Empty input yields an empty slice. +// into individual buffer specs. Empty input yields an empty slice. The +// size must be a plain decimal number over the whole field and the pattern +// must be a known name or a valid hex blob, so typos fail here with the +// offending spec in the message rather than silently allocating a zeroed +// buffer. func ParseBufSpec(spec string) ([]BufSpec, error) { if spec == "" { return nil, nil @@ -31,15 +35,38 @@ func ParseBufSpec(spec string) ([]BufSpec, error) { if len(fields) != 3 { return nil, fmt.Errorf("verify: invalid buffer spec %q (expected name:size:pattern)", part) } - var size int - if _, err := fmt.Sscanf(fields[1], "%d", &size); err != nil || size <= 0 { + // strconv.Atoi parses the whole field, unlike fmt.Sscanf which + // accepts trailing garbage ("1024abc" parsed as 1024). + size, err := strconv.Atoi(fields[1]) + if err != nil || size <= 0 { return nil, fmt.Errorf("verify: invalid buffer size %q in %q", fields[1], part) } + if err := validPattern(fields[2]); err != nil { + return nil, fmt.Errorf("verify: invalid pattern %q in %q: %v", fields[2], part, err) + } out = append(out, BufSpec{Name: fields[0], Size: size, Pattern: fields[2]}) } return out, nil } +// validPattern checks one buffer pattern: a known name, or a non-empty hex +// blob. fillBuffer enforces the same rule again at fill time for specs +// that were not built by ParseBufSpec. +func validPattern(pattern string) error { + switch pattern { + case "zero", "ones", "seq": + return nil + } + data, err := hex.DecodeString(pattern) + if err != nil { + return fmt.Errorf("not a known pattern (zero, ones, seq) and not hex: %w", err) + } + if len(data) == 0 { + return fmt.Errorf("not a known pattern (zero, ones, seq) and the hex blob is empty") + } + return nil +} + // allocatedBuf is one live buffer in a pool. type allocatedBuf struct { spec BufSpec @@ -60,11 +87,15 @@ type BufPool struct { } // Alloc allocates and fills the buffers described by specs. The returned -// pool must be kept alive until every call using it has returned. +// pool must be kept alive until every call using it has returned. An +// unknown pattern name or invalid hex blob is an error rather than a +// silently zeroed buffer. func (p *BufPool) Alloc(specs []BufSpec) error { for _, s := range specs { data := make([]byte, s.Size+safetyMargin) - fillBuffer(data, s.Pattern) + if err := fillBuffer(data, s.Pattern); err != nil { + return fmt.Errorf("verify: buffer %q: %w", s.Name, err) + } p.bufs = append(p.bufs, allocatedBuf{spec: s, data: data}) } return nil @@ -128,8 +159,10 @@ func (p *BufPool) matchBuf(name string) *allocatedBuf { } // fillBuffer fills buf with the named pattern: "zero" (no-op, already zeroed), -// "ones" (0xFF), "seq" (i mod 256), or a hex blob repeated to fill. -func fillBuffer(buf []byte, pattern string) { +// "ones" (0xFF), "seq" (i mod 256), or a hex blob repeated to fill. An +// unknown pattern name or undecodable hex returns an error instead of +// leaving the buffer silently zeroed. +func fillBuffer(buf []byte, pattern string) error { switch pattern { case "zero": // Already zeroed by make. @@ -142,12 +175,18 @@ func fillBuffer(buf []byte, pattern string) { buf[i] = byte(i) } default: - if data, err := hex.DecodeString(pattern); err == nil && len(data) > 0 { - for i := range buf { - buf[i] = data[i%len(data)] - } + data, err := hex.DecodeString(pattern) + if err != nil { + return fmt.Errorf("unknown pattern (want zero, ones, seq or hex): %w", err) + } + if len(data) == 0 { + return fmt.Errorf("unknown pattern (want zero, ones, seq or hex): empty hex blob") + } + for i := range buf { + buf[i] = data[i%len(data)] } } + return nil } // ApplyScalarArgs writes user-supplied scalar argument values into an ABI0 diff --git a/verify/buffers_test.go b/verify/buffers_test.go index 862c51b..a35b71c 100644 --- a/verify/buffers_test.go +++ b/verify/buffers_test.go @@ -115,6 +115,48 @@ func TestParseBufSpec(t *testing.T) { t.Error("ParseBufSpec(\"dst:0:zero\") should error on zero size") } }) + + t.Run("trailing-garbage-size", func(t *testing.T) { + // fmt.Sscanf("%d") accepted "1024abc" as 1024; the strict parse + // must reject the whole field. + if _, err := ParseBufSpec("dst:1024abc:zero"); err == nil { + t.Error("ParseBufSpec(\"dst:1024abc:zero\") should reject a size with trailing garbage") + } + }) + + t.Run("hex-size", func(t *testing.T) { + if _, err := ParseBufSpec("dst:0x40:zero"); err == nil { + t.Error("ParseBufSpec(\"dst:0x40:zero\") should reject a non-decimal size") + } + }) + + t.Run("unknown-pattern", func(t *testing.T) { + if _, err := ParseBufSpec("dst:64:wibble"); err == nil { + t.Error("ParseBufSpec(\"dst:64:wibble\") should reject an unknown pattern") + } + }) + + t.Run("bad-hex-pattern", func(t *testing.T) { + if _, err := ParseBufSpec("dst:64:zz"); err == nil { + t.Error("ParseBufSpec(\"dst:64:zz\") should reject undecodable hex") + } + }) + + t.Run("empty-pattern", func(t *testing.T) { + if _, err := ParseBufSpec("dst:64:"); err == nil { + t.Error("ParseBufSpec(\"dst:64:\") should reject an empty pattern") + } + }) + + t.Run("hex-pattern", func(t *testing.T) { + specs, err := ParseBufSpec("dst:6:deadbeef") + if err != nil { + t.Fatal(err) + } + if len(specs) != 1 || specs[0].Pattern != "deadbeef" { + t.Errorf("ParseBufSpec(\"dst:6:deadbeef\") = %+v", specs) + } + }) } func TestBufPoolBuildArgs(t *testing.T) { diff --git a/verify/call_amd64.go b/verify/call_amd64.go index 8acd2c5..5b68b49 100644 --- a/verify/call_amd64.go +++ b/verify/call_amd64.go @@ -58,6 +58,10 @@ const stackPad = 64 // // The function must be NOSPLIT (no stack growth) and must not reference // external symbols, the image is self-contained. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals +// (savedSP and friends). func Call(fnAddr uintptr, args []byte) ([]byte, error) { // Prepare the stack: [padding][leaveJIT addr][args...] stackSize := stackPad + 8 + len(args) + 64 // padding + ret + args + safety diff --git a/verify/call_arm64.go b/verify/call_arm64.go index aa2cc91..2a9ba83 100644 --- a/verify/call_arm64.go +++ b/verify/call_arm64.go @@ -34,6 +34,9 @@ const stackPad = 64 // Call invokes the assembled function at fnAddr with the given ABI0 argument // block. On arm64, the ABI0 convention places arguments on the stack starting // at [SP+8] (same as amd64 ABI0 in this project). +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func Call(fnAddr uintptr, args []byte) ([]byte, error) { stackSize := stackPad + 8 + len(args) + 64 stackMem, err := syscall.Mmap(-1, 0, stackSize, diff --git a/verify/call_loong64.go b/verify/call_loong64.go index 5d2b622..4df69a3 100644 --- a/verify/call_loong64.go +++ b/verify/call_loong64.go @@ -8,7 +8,6 @@ package verify import ( "encoding/binary" "fmt" - "reflect" "syscall" "unsafe" ) @@ -19,15 +18,20 @@ func enterJIT(fn uintptr, stack uintptr) //go:nosplit func leaveJIT() -var leaveJITAddr uintptr +// leaveRawAddr is the raw ABI0 address of leaveJIT, handed over by the +// GLOBL/DATA in abi_loong64.s (reflect would return the interposed +// ABIInternal wrapper instead: its prologue clobbers the saved-register +// window the JIT call depends on). +var leaveRawAddr uintptr -func init() { - leaveJITAddr = reflect.ValueOf(leaveJIT).Pointer() -} +var leaveJITAddr = leaveRawAddr const stackPad = 64 // Call invokes the assembled function at fnAddr with the given ABI0 argument block. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func Call(fnAddr uintptr, args []byte) ([]byte, error) { stackSize := stackPad + 8 + len(args) + 64 stackMem, err := syscall.Mmap(-1, 0, stackSize, diff --git a/verify/call_other.go b/verify/call_other.go index 8180a94..0fd5a38 100644 --- a/verify/call_other.go +++ b/verify/call_other.go @@ -7,7 +7,7 @@ package verify import "fmt" -// Call is unavailable on non-amd64 architectures. +// Call is unavailable off the four supported architectures. func Call(fnAddr uintptr, args []byte) ([]byte, error) { - return nil, fmt.Errorf("verify: JIT execution requires amd64") + return nil, fmt.Errorf("verify: JIT execution requires amd64, arm64, riscv64 or loong64") } diff --git a/verify/call_riscv64.go b/verify/call_riscv64.go index c14177e..9ec19f8 100644 --- a/verify/call_riscv64.go +++ b/verify/call_riscv64.go @@ -28,6 +28,9 @@ func init() { const stackPad = 64 // Call invokes the assembled function at fnAddr with the given ABI0 argument block. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func Call(fnAddr uintptr, args []byte) ([]byte, error) { stackSize := stackPad + 8 + len(args) + 64 stackMem, err := syscall.Mmap(-1, 0, stackSize, diff --git a/verify/corpus_test.go b/verify/corpus_test.go index 2aa9206..0f3ee10 100644 --- a/verify/corpus_test.go +++ b/verify/corpus_test.go @@ -4,8 +4,8 @@ package verify import ( + "encoding/hex" "encoding/json" - "math/rand" "os" "runtime" "strconv" @@ -14,6 +14,7 @@ import ( func loadBasicKernel(t *testing.T) *Kernel { t.Helper() + requireHost(t, "amd64") k, err := Load("../testdata/verify/basic_amd64.s") if err != nil { t.Fatalf("Load: %v", err) @@ -71,7 +72,7 @@ func TestGenDualArgsEntryReplayable(t *testing.T) { if !ok { t.Fatal("parseFuncSig failed") } - _, _, bufs, entry := genDualArgs(rand.New(rand.NewSource(1)), sig, 24) + _, _, bufs, entry := genDualArgs(newRNG(1), sig, 24) if len(entry.Args) != 2 || entry.Args[0].Kind != "int" { t.Fatalf("unexpected entry: %+v", entry) } @@ -96,11 +97,88 @@ func entryInt(t *testing.T, a CorpusArg) int64 { return int64(v) } +// strProbeSrc is a kernel that consumes an ABI0 string header: it +// dereferences the data pointer (proving it points at live memory) and +// returns len(s) + s[0] when the string is non-empty, len(s) otherwise. +const strProbeSrc = `#include "textflag.h" + +// func strProbe(s string) int64 +TEXT ·strProbe(SB), NOSPLIT, $0-24 + MOVQ s_base+0(FP), SI + MOVQ s_len+8(FP), CX + XORQ AX, AX + TESTQ CX, CX + JZ probe_done + MOVB (SI), AL + ADDQ AX, CX + +probe_done: + MOVQ CX, ret+16(FP) + RET +` + +func loadStrProbeKernel(t *testing.T) *Kernel { + t.Helper() + requireHost(t, "amd64") + file := t.TempDir() + "/strprobe_amd64.s" + if err := os.WriteFile(file, []byte(strProbeSrc), 0o644); err != nil { + t.Fatalf("write kernel: %v", err) + } + k, err := Load(file) + if err != nil { + t.Fatalf("Load: %v", err) + } + t.Cleanup(k.Close) + return k +} + +// TestStringParamRoundTrip pins the ABI0 string marshalling end to end: +// genDualArgs lays a string parameter out as a two-word header pointing at +// a live buffer, the corpus entry records it, and ReplayEntry rebuilds an +// equivalent header. +func TestStringParamRoundTrip(t *testing.T) { + k := loadStrProbeKernel(t) + + sig, ok := parseFuncSig("// func strProbe(s string) int64") + if !ok { + t.Fatal("parseFuncSig failed") + } + args, _, bufs, entry := genDualArgs(newRNG(7), sig, 24) + if len(entry.Args) != 1 || entry.Args[0].Kind != "string" { + t.Fatalf("unexpected entry: %+v", entry) + } + + out, err := k.CallFunc("strProbe", args) + if err != nil { + t.Fatalf("CallFunc: %v", err) + } + live := int64(GetUint64(out, 16)) + want := int64(entry.Args[0].Len) + if d, err := hex.DecodeString(entry.Args[0].Data); err != nil { + t.Fatalf("entry data: %v", err) + } else if len(d) > 0 { + want += int64(d[0]) + } + if live != want { + t.Errorf("live call = %d, want %d (len + first byte)", live, want) + } + + replayed, err := k.ReplayEntry("strProbe", entry) + if err != nil { + t.Fatalf("ReplayEntry: %v", err) + } + if got := int64(GetUint64(replayed, 16)); got != want { + t.Errorf("replayed call = %d, want %d", got, want) + } + runtime.KeepAlive(bufs) +} + // TestFuzzHookSavesFailures fuzzes add against the go-tool-asm build of a // sub kernel with the same signature, so every iteration mismatches (safely: // both kernels read only their own arguments) and the hook must record // replayable entries. func TestFuzzHookSavesFailures(t *testing.T) { + requireHost(t, "amd64") src := `#include "textflag.h" // func add(a, b int) int diff --git a/verify/coverage.go b/verify/coverage.go index c456838..5b10b0a 100644 --- a/verify/coverage.go +++ b/verify/coverage.go @@ -5,7 +5,7 @@ package verify import ( "fmt" - "sort" + "slices" ) // Block describes one basic block within a function: a maximal sequence of @@ -13,7 +13,7 @@ import ( // a single exit (a jump, conditional jump or RET). type Block struct { Offset int // byte offset within the function - Label string // label name ("" for the entry block) + Label string // label name ("(entry)" for the function entry block) } // Blocks identifies the basic blocks of a function from its local labels. @@ -40,7 +40,7 @@ func (k *Kernel) Blocks(name string) ([]Block, error) { for off := range offToLabel { offsets = append(offsets, off) } - sort.Ints(offsets) + slices.Sort(offsets) for _, off := range offsets { blocks = append(blocks, Block{Offset: off, Label: offToLabel[off]}) } diff --git a/verify/fuzz.go b/verify/fuzz.go index 1985060..19be700 100644 --- a/verify/fuzz.go +++ b/verify/fuzz.go @@ -6,7 +6,7 @@ package verify import ( "encoding/hex" "fmt" - "math/rand" + "math/rand/v2" "regexp" "runtime" "strconv" @@ -14,6 +14,28 @@ import ( "unsafe" ) +// newRNG builds the deterministic generator for a fuzz seed. PCG seeds +// with two 64-bit words; deriving the second from the first keeps one seed +// one stream and rules out the all-zero seed. The sequences differ from +// the retired math/rand ones for the same seed, but remain reproducible +// run to run, which is the property the fuzzers rely on. +func newRNG(seed int64) *rand.Rand { + lo := uint64(seed) + return rand.New(rand.NewPCG(lo, ^lo)) +} + +// fillRandom fills buf from rng, eight bytes per draw. math/rand/v2 +// dropped Read from *rand.Rand, and this loop keeps the byte sequence a +// pure function of the generator state. +func fillRandom(rng *rand.Rand, buf []byte) { + for off := 0; off < len(buf); off += 8 { + v := rng.Uint64() + for j := 0; j < 8 && off+j < len(buf); j++ { + buf[off+j] = byte(v >> (8 * uint(j))) + } + } +} + // FuzzResult reports the outcome of a differential fuzz campaign for one // function. type FuzzResult struct { @@ -43,9 +65,9 @@ func (r FuzzResult) String() string { // CorpusArg is one replayable argument of a corpus entry. type CorpusArg struct { - Kind string `json:"kind"` // "slice", "ptr", "int", "scalar" - Len int `json:"len,omitempty"` // slice: declared length in elements - Data string `json:"data,omitempty"` // slice/ptr: hex-encoded buffer content + Kind string `json:"kind"` // "slice", "string", "ptr", "int", "scalar" + Len int `json:"len,omitempty"` // slice: declared length in elements; string: length in bytes + Data string `json:"data,omitempty"` // slice/string/ptr: hex-encoded buffer content Value string `json:"value,omitempty"` // int/scalar: decimal value } @@ -175,6 +197,9 @@ func ExtractSignatures(src string) map[string]funcSig { // // The signature comment must appear immediately above the TEXT directive // in the source (the conventional Go assembly layout). +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations int, seed int64) FuzzResult { return k.FuzzFuncHook(name, sig, goCode, iterations, seed, nil) } @@ -182,10 +207,13 @@ func (k *Kernel) FuzzFunc(name string, sig funcSig, goCode []byte, iterations in // FuzzFuncHook is FuzzFunc with a hook invoked for every failing input (a // crash or a mismatch), receiving a replayable corpus entry. A nil hook // behaves exactly like FuzzFunc. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func (k *Kernel) FuzzFuncHook(name string, sig funcSig, goCode []byte, iterations int, seed int64, onSave func(CorpusEntry)) FuzzResult { result := FuzzResult{Func: name, Iterations: iterations} - rng := rand.New(rand.NewSource(seed)) + rng := newRNG(seed) // Map the Go-assembled code into a second executable region. goExec, err := Map(goCode) @@ -203,6 +231,18 @@ func (k *Kernel) FuzzFuncHook(name string, sig funcSig, goCode []byte, iteration return result } + // The result comparison below slices the parameter area off the + // argument block; a // func comment declaring more parameter bytes + // than the TEXT frame carries would slice past its end and panic. + // Fail the whole campaign with a clear message instead. + if ps := paramsSize(sig); ps > fl.Args { + result.Mismatches = iterations + result.FirstFail = fmt.Sprintf( + "signature declares %d parameter bytes, but the TEXT frame of %s carries %d argument bytes", + ps, name, fl.Args) + return result + } + for i := range iterations { // Generate inputs and build TWO independent arg blocks (one per // version) so that functions which write to their arguments @@ -276,7 +316,7 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b switch { case strings.HasPrefix(p.typ, "[]"): elemSize := elemSizeFor(p.typ) - n := 1 + rng.Intn(127) + n := 1 + rng.IntN(127) var declaredLen int if sliceIdx == 0 { declaredLen = n @@ -290,7 +330,7 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b // Two independent buffers with identical random content. buf1 := make([]byte, bufBytes) buf2 := make([]byte, bufBytes) - rng.Read(buf1[:n*elemSize]) + fillRandom(rng, buf1[:n*elemSize]) copy(buf2, buf1) bufs = append(bufs, buf1, buf2) putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0])) @@ -309,13 +349,35 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b Data: hex.EncodeToString(buf1[:n*elemSize]), }) + case p.typ == "string": + // An ABI0 string is a two-word header (data pointer + + // length); a random pointer would fault kernels that read + // the string, so the header points at a real buffer with + // the same safety margin slices get. + n := 1 + rng.IntN(127) + buf1 := make([]byte, n+8192) + buf2 := make([]byte, n+8192) + fillRandom(rng, buf1[:n]) + copy(buf2, buf1) + bufs = append(bufs, buf1, buf2) + putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0])) + putPtr(goArgs, off, unsafe.Pointer(&buf2[0])) + putU64(gasmArgs, off+8, uint64(n)) + putU64(goArgs, off+8, uint64(n)) + off += 16 + entry.Args = append(entry.Args, CorpusArg{ + Kind: "string", + Len: n, + Data: hex.EncodeToString(buf1[:n]), + }) + case strings.HasPrefix(p.typ, "*["): nElem := arrayLen(p.typ) elem := elemSizeFor("[]" + p.typ[strings.Index(p.typ, "]")+1:]) size := max(nElem*elem, 8) buf1 := make([]byte, size) buf2 := make([]byte, size) - rng.Read(buf1) + fillRandom(rng, buf1) copy(buf2, buf1) bufs = append(bufs, buf1, buf2) putPtr(gasmArgs, off, unsafe.Pointer(&buf1[0])) @@ -327,12 +389,24 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b }) case p.typ == "int" || p.typ == "uint" || p.typ == "int64" || p.typ == "uint64": - v := uint64(rng.Intn(256)) + v := uint64(rng.IntN(256)) putU64(gasmArgs, off, v) putU64(goArgs, off, v) off += 8 entry.Args = append(entry.Args, CorpusArg{Kind: "int", Value: strconv.FormatUint(v, 10)}) + case p.typ == "complex64", p.typ == "complex128": + // complex64 is two float32s (8 bytes), complex128 two + // float64s (16): plain data words to the marshaller, one + // corpus scalar per word so replay rebuilds them exactly. + for range paramSize(p.typ) / 8 { + v := rng.Uint64() + putU64(gasmArgs, off, v) + putU64(goArgs, off, v) + off += 8 + entry.Args = append(entry.Args, CorpusArg{Kind: "scalar", Value: strconv.FormatUint(v, 10)}) + } + default: v := rng.Uint64() putU64(gasmArgs, off, v) @@ -346,8 +420,12 @@ func genDualArgs(rng *rand.Rand, sig funcSig, argSize int) (gasmArgs, goArgs []b // ReplayEntry rebuilds the argument block of a corpus entry and invokes the // named function once, returning the argument block after the call. Slice -// buffers get the same safety padding the fuzzer uses, so over-reads that -// were harmless during the original run stay harmless on replay. +// and string buffers get the same safety padding the fuzzer uses, so +// over-reads that were harmless during the original run stay harmless on +// replay. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func (k *Kernel) ReplayEntry(name string, e CorpusEntry) ([]byte, error) { fl, err := k.Func(name) if err != nil { @@ -374,6 +452,21 @@ func (k *Kernel) ReplayEntry(name string, e CorpusEntry) ([]byte, error) { putU64(args, off+16, uint64(a.Len)) off += 24 + case "string": + data, err := hex.DecodeString(a.Data) + if err != nil { + return nil, fmt.Errorf("corpus: string data: %w", err) + } + buf := make([]byte, len(data)+8192) + copy(buf, data) + bufs = append(bufs, buf) + if off+16 > len(args) { + return nil, fmt.Errorf("corpus: entry does not fit the argument block of %s", name) + } + putPtr(args, off, unsafe.Pointer(&buf[0])) + putU64(args, off+8, uint64(a.Len)) + off += 16 + case "ptr": data, err := hex.DecodeString(a.Data) if err != nil { @@ -431,6 +524,12 @@ func paramsSize(sig funcSig) int { size += 8 // pointer case p.typ == "bool": size += 1 + case p.typ == "string": + size += 16 // data pointer + length + case p.typ == "complex64": + size += 8 // two float32s + case p.typ == "complex128": + size += 16 // two float64s default: size += 8 // int, uint, etc. } diff --git a/verify/fuzz_extra_test.go b/verify/fuzz_extra_test.go index 554c8c5..ca983f7 100644 --- a/verify/fuzz_extra_test.go +++ b/verify/fuzz_extra_test.go @@ -90,6 +90,16 @@ func TestParamsSize(t *testing.T) { if got := paramsSize(sig); got != 32 { t.Errorf("paramsSize = %d, want 32 (24 for slice + 8 for int)", got) } + + // ABI0 strings are ptr+len (16), complex64 packs two float32s (8), + // complex128 two float64s (16). + sig = funcSig{ + name: "hdr", + params: []param{{name: "s", typ: "string"}, {name: "c64", typ: "complex64"}, {name: "c128", typ: "complex128"}}, + } + if got := paramsSize(sig); got != 40 { + t.Errorf("paramsSize = %d, want 40 (16 string + 8 complex64 + 16 complex128)", got) + } } func TestBlockCount(t *testing.T) { @@ -103,11 +113,34 @@ func TestBlockCount(t *testing.T) { } } +// TestNewRNGDeterministic pins the reproducibility contract of the fuzz +// seeds: the same seed must rebuild the same PCG stream, including the +// sub-word tail of fillRandom. +func TestNewRNGDeterministic(t *testing.T) { + draw := func() []byte { + rng := newRNG(7) + out := make([]byte, 20) // 8+8+4: exercises a full word and a tail + fillRandom(rng, out) + return out + } + a, b := draw(), draw() + for i := range a { + if a[i] != b[i] { + t.Fatalf("seed 7 produced different bytes at %d: %02x vs %02x", i, a[i], b[i]) + } + } + if newRNG(0) == nil { + t.Fatal("newRNG(0) must build a generator") + } +} + func TestFillBuffer(t *testing.T) { t.Run("zero", func(t *testing.T) { // fillBuffer("zero") is a no-op; relies on make already zeroing. buf := make([]byte, 16) - fillBuffer(buf, "zero") + if err := fillBuffer(buf, "zero"); err != nil { + t.Fatalf("fillBuffer(zero): %v", err) + } for _, b := range buf { if b != 0 { t.Error("zero pattern: make should produce zeroed buffer") @@ -117,7 +150,9 @@ func TestFillBuffer(t *testing.T) { }) t.Run("ones", func(t *testing.T) { buf := make([]byte, 16) - fillBuffer(buf, "ones") + if err := fillBuffer(buf, "ones"); err != nil { + t.Fatalf("fillBuffer(ones): %v", err) + } for _, b := range buf { if b != 0xFF { t.Error("ones pattern should fill with 0xFF") @@ -127,7 +162,9 @@ func TestFillBuffer(t *testing.T) { }) t.Run("seq", func(t *testing.T) { buf := make([]byte, 256) - fillBuffer(buf, "seq") + if err := fillBuffer(buf, "seq"); err != nil { + t.Fatalf("fillBuffer(seq): %v", err) + } for i, b := range buf { if b != byte(i) { t.Errorf("seq[%d] = %d, want %d", i, b, i) @@ -137,7 +174,9 @@ func TestFillBuffer(t *testing.T) { }) t.Run("hex", func(t *testing.T) { buf := make([]byte, 6) - fillBuffer(buf, "deadbeef") + if err := fillBuffer(buf, "deadbeef"); err != nil { + t.Fatalf("fillBuffer(deadbeef): %v", err) + } want := []byte{0xDE, 0xAD, 0xBE, 0xEF, 0xDE, 0xAD} for i, b := range buf { if b != want[i] { @@ -146,6 +185,34 @@ func TestFillBuffer(t *testing.T) { } } }) + t.Run("unknown-pattern", func(t *testing.T) { + buf := make([]byte, 8) + if err := fillBuffer(buf, "wibble"); err == nil { + t.Error("fillBuffer(wibble): expected an error for an unknown pattern name") + } + }) + t.Run("bad-hex", func(t *testing.T) { + buf := make([]byte, 8) + if err := fillBuffer(buf, "zz"); err == nil { + t.Error("fillBuffer(zz): expected an error for undecodable hex") + } + }) + t.Run("empty-hex", func(t *testing.T) { + buf := make([]byte, 8) + if err := fillBuffer(buf, ""); err == nil { + t.Error("fillBuffer(\"\"): expected an error for an empty pattern") + } + }) +} + +// TestBufPoolAllocBadPattern checks that Alloc surfaces fill errors under +// the buffer's name, so hand-built specs fail as loudly as parsed ones. +func TestBufPoolAllocBadPattern(t *testing.T) { + var pool BufPool + defer pool.Close() + if err := pool.Alloc([]BufSpec{{Name: "dst", Size: 16, Pattern: "nope"}}); err == nil { + t.Fatal("Alloc with an unknown pattern must fail") + } } func TestFuzzFuncChecked(t *testing.T) { diff --git a/verify/fuzz_test.go b/verify/fuzz_test.go index e19e842..a8460ee 100644 --- a/verify/fuzz_test.go +++ b/verify/fuzz_test.go @@ -4,6 +4,8 @@ package verify import ( + "os" + "strings" "testing" ) @@ -65,6 +67,72 @@ func TestFuzzWideCopy(t *testing.T) { } } +// TestFuzzFuncSigWiderThanFrame pins the guard against a // func comment +// that declares more parameter bytes than the TEXT frame carries: before +// the guard, slicing the result area at paramsSize(sig) past the end of +// the argument block panicked the whole test binary. +func TestFuzzFuncSigWiderThanFrame(t *testing.T) { + k := loadBasic(t) + + gt, err := GroundTruth("../testdata/verify/basic_amd64.s") + if err != nil { + t.Skipf("go tool asm unavailable: %v", err) + } + goCode, ok := gt["add"] + if !ok { + t.Skip("add not in ground truth") + } + + // add carries $0-24; four int parameters declare 32 bytes. + sig := funcSig{ + name: "add", + params: []param{ + {name: "a", typ: "int"}, + {name: "b", typ: "int"}, + {name: "c", typ: "int"}, + {name: "d", typ: "int"}, + }, + } + + res := k.FuzzFunc("add", sig, goCode, 3, 42) + if res.OK() { + t.Fatal("expected the over-wide signature to fail the campaign") + } + if !strings.Contains(res.FirstFail, "parameter bytes") || !strings.Contains(res.FirstFail, "argument bytes") { + t.Errorf("FirstFail = %q, want a clear signature-versus-frame message", res.FirstFail) + } +} + +// TestFuzzStringParam runs the differential fuzzer over a kernel whose +// only parameter is a string: the marshaller lays out a real two-word +// header (data pointer + length) and the comparison slices at +// paramsSize(sig) = 16, so the length word is input, not result. +func TestFuzzStringParam(t *testing.T) { + k := loadStrProbeKernel(t) + + file := t.TempDir() + "/strprobe_amd64.s" + if err := os.WriteFile(file, []byte(strProbeSrc), 0o644); err != nil { + t.Fatalf("write kernel: %v", err) + } + gt, err := GroundTruth(file) + if err != nil { + t.Skipf("go tool asm unavailable: %v", err) + } + goCode, ok := gt["strProbe"] + if !ok { + t.Skip("strProbe not in ground truth") + } + + sig, ok := parseFuncSig("// func strProbe(s string) int64") + if !ok { + t.Fatal("parseFuncSig failed") + } + res := k.FuzzFunc("strProbe", sig, goCode, 100, 42) + if !res.OK() { + t.Errorf("strProbe fuzz: %s", res) + } +} + func TestParseFuncSig(t *testing.T) { tests := []struct { comment string diff --git a/verify/groundtruth.go b/verify/groundtruth.go index ac65cd6..9b1e967 100644 --- a/verify/groundtruth.go +++ b/verify/groundtruth.go @@ -20,8 +20,8 @@ import ( // This is the universal oracle: any file that `go tool asm` accepts can // be verified, with no hand-written reference. // -// For RISC-V sources the assembler is invoked with GOARCH=riscv64; -// the caller must set the architecture via GroundTruthArch. +// For the other architectures use the cross-assembly entry points +// GroundTruthARM64, GroundTruthRISCV and GroundTruthLOONG64. func GroundTruth(path string) (map[string][]byte, error) { return groundTruthArch(path, "") } @@ -82,7 +82,19 @@ func groundTruthArch(path, goarch string) (map[string][]byte, error) { cmd := exec.Command(asmBin, "-I", includeDir, "-p", pkg, "-o", objPath, path) if goarch != "" { - cmd.Env = append(os.Environ(), "GOARCH="+goarch) + // Replace, do not duplicate, any GOARCH the ambient environment + // exports. The Go runtime resolves duplicated keys last-wins, + // so the appended entry happens to win today; a single + // unambiguous entry keeps the child's target architecture from + // depending on that resolution order. + environ := os.Environ() + env := make([]string, 0, len(environ)+1) + for _, e := range environ { + if !strings.HasPrefix(e, "GOARCH=") { + env = append(env, e) + } + } + cmd.Env = append(env, "GOARCH="+goarch) } if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("verify: go tool asm (%s): %w\n%s", goarch, err, out) diff --git a/verify/groundtruth_test.go b/verify/groundtruth_test.go index a9acbe9..d411356 100644 --- a/verify/groundtruth_test.go +++ b/verify/groundtruth_test.go @@ -82,6 +82,22 @@ func TestGroundTruthBadFile(t *testing.T) { } } +// TestGroundTruthCrossEnvGOARCH checks that a GOARCH exported in the +// ambient environment does not override cross-assembly: the child +// assembler must honour the requested GOARCH even when the environment +// already carries a GOARCH entry (os.Getenv reads the first entry, so a +// duplicate would win). +func TestGroundTruthCrossEnvGOARCH(t *testing.T) { + t.Setenv("GOARCH", "amd64") + gt, err := GroundTruthARM64("../testdata/verify/basic_arm64.s") + if err != nil { + t.Fatalf("GroundTruthARM64 with ambient GOARCH=amd64: %v", err) + } + if len(gt) == 0 { + t.Fatal("no functions extracted from ground truth") + } +} + func keys(m map[string][]byte) []string { out := make([]string, 0, len(m)) for k := range m { diff --git a/verify/jit_test.go b/verify/jit_test.go index 4b4218a..d808170 100644 --- a/verify/jit_test.go +++ b/verify/jit_test.go @@ -12,6 +12,7 @@ import ( func loadBasic(t *testing.T) *Kernel { t.Helper() + requireHost(t, "amd64") k, err := Load("../testdata/verify/basic_amd64.s") if err != nil { t.Fatalf("Load: %v", err) diff --git a/verify/sigparse.go b/verify/sigparse.go index bdc4adc..c72a75f 100644 --- a/verify/sigparse.go +++ b/verify/sigparse.go @@ -153,6 +153,13 @@ func paramSize(typ string) int { return 2 case typ == "int8", typ == "uint8", typ == "byte", typ == "bool": return 1 + case typ == "string": + // ABI0 strings are a two-word header: data pointer + length. + return 16 + case typ == "complex64": + return 8 + case typ == "complex128": + return 16 default: // Default to 8 bytes for unknown types. return 8 diff --git a/verify/sigparse_test.go b/verify/sigparse_test.go index aabf2e5..8f1bb1f 100644 --- a/verify/sigparse_test.go +++ b/verify/sigparse_test.go @@ -143,7 +143,10 @@ func TestParamSize(t *testing.T) { {"uint8", 1}, {"byte", 1}, {"bool", 1}, - {"string", 8}, // unknown type defaults to 8 + {"string", 16}, // ABI0 header: data pointer + length + {"complex64", 8}, + {"complex128", 16}, + {"error", 8}, // unknown type defaults to 8 } for _, tt := range tests { if got := paramSize(tt.typ); got != tt.want { diff --git a/verify/trampoline_arm64.s b/verify/trampoline_arm64.s index 1e3aeef..af9e39e 100644 --- a/verify/trampoline_arm64.s +++ b/verify/trampoline_arm64.s @@ -17,7 +17,8 @@ TEXT ·enterJIT(SB), NOSPLIT, $0-16 MOVD fn+0(FP), R0 // target function address MOVD R30, savedLR(SB) // save link register - MOVD R3, savedSP(SB) // save Go stack pointer + MOVD RSP, R3 // save Go stack pointer: R3 relays the value, the + MOVD R3, savedSP(SB) // arm64 assembler cannot store RSP to memory MOVD stack+8(FP), R3 // load prepared stack pointer MOVD 0(R3), R30 // load leaveJIT address into LR MOVD R3, RSP // switch to the prepared stack: SP stays on diff --git a/verify/verify.go b/verify/verify.go index 0ccf505..760e9d2 100644 --- a/verify/verify.go +++ b/verify/verify.go @@ -5,7 +5,6 @@ package verify import ( "fmt" - "math/rand" "os" "runtime" @@ -101,6 +100,9 @@ func (k *Kernel) FuncNames() []string { // The arg block is the raw bytes of the function's argument/result area // (as declared by the TEXT $frame-args suffix). Returns the arg block // after the call (with any results written back by the function). +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func (k *Kernel) CallFunc(name string, args []byte) ([]byte, error) { idx, ok := k.funcs[name] if !ok { @@ -116,7 +118,12 @@ func (k *Kernel) CallFunc(name string, args []byte) ([]byte, error) { // CallFuncChecked invokes the named function with ABI sentinels and a // red-zone canary, returning the argument block and an ABIReport that -// records any callee-saved register or red-zone violations. +// records any callee-saved register or red-zone violations. The canary +// gap is sized from the function's declared frame, so legal frame writes +// do not count as violations. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func (k *Kernel) CallFuncChecked(name string, args []byte) ([]byte, ABIReport, error) { idx, ok := k.funcs[name] if !ok { @@ -127,11 +134,14 @@ func (k *Kernel) CallFuncChecked(name string, args []byte) ([]byte, ABIReport, e return nil, ABIReport{}, fmt.Errorf("verify: %s: arg block too small: got %d, need %d", name, len(args), fl.Args) } fnAddr := k.exec.FuncAddr(fl.Offset) - return CallChecked(fnAddr, args) + return CallCheckedFrame(fnAddr, args, fl.Frame) } // FuzzFuncCheckedByName is like FuzzFuncChecked but extracts the signature // from the source code internally. +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func (k *Kernel) FuzzFuncCheckedByName(name, src string, iterations int, seed int64) FuzzResult { result := FuzzResult{Func: name, Iterations: iterations} sig, ok := ExtractSignatures(src)[name] @@ -146,9 +156,12 @@ func (k *Kernel) FuzzFuncCheckedByName(name, src string, iterations int, seed in // FuzzFuncChecked combines fuzzing with ABI checks: it generates varied // inputs and verifies that callee-saved registers and the red zone are // preserved even on deep execution paths (not just early exits). +// +// Not safe for concurrent use: only one JIT call may be in flight at a +// time, the trampolines keep the saved registers in package globals. func (k *Kernel) FuzzFuncChecked(name string, sig funcSig, iterations int, seed int64) FuzzResult { result := FuzzResult{Func: name, Iterations: iterations} - rng := rand.New(rand.NewSource(seed)) + rng := newRNG(seed) fl, err := k.Func(name) if err != nil {