Files
gasm-sdk/asm/assembler_fuzz_test.go
T

289 lines
15 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: BSD-3-Clause
package asm
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"sourcedock.dev/petrbalvin/gasm-sdk/ast"
"sourcedock.dev/petrbalvin/gasm-sdk/parser"
)
// fuzzIncludeDirs points the expansion path at the package's testdata include
// directory, so a seed's #include resolves the way the CLI's -I list does.
var fuzzIncludeDirs = []string{filepath.Join("testdata", "include")}
// corpusSeeds seeds every fuzz target with the repository's kernels, so a
// plain `go test` run replays each seed as a regression case and CI exercises
// them without any fuzzing budget. Kernels of a foreign architecture
// exercise the rejection path (the fixed target reports them as diagnostics);
// kernels of the target's own architecture reach its encoder.
func corpusSeeds(f *testing.F) {
for _, pattern := range []string{
"../testdata/*.s",
"../testdata/verify/*.s",
} {
files, _ := filepath.Glob(pattern)
for _, path := range files {
if b, err := os.ReadFile(path); err == nil {
f.Add(string(b))
}
}
}
}
// archCorpusSeeds seeds a target with every assembly file of its architecture
// seed directory, testdata/seeds/<dir>. The files hold the target-specific
// corpus: the instruction families GOROOT's own assembler corpus exercises for
// the architecture, minimalised, plus the boundary shapes the encoder's range
// gates live on. They are committed assembly, so `gasm fmt` and `gasm lint`
// gate them the way they gate every other .s file. A plain `go test` run
// replays each one as a regression case.
func archCorpusSeeds(f *testing.F, dir string) {
files, _ := filepath.Glob(filepath.Join("testdata", "seeds", dir, "*.s"))
for _, path := range files {
if b, err := os.ReadFile(path); err == nil {
f.Add(string(b))
}
}
}
// fuzzAssemble is the whole fuzz body, shared by every target and one line
// apart between them: parse with macro and include expansion, assemble
// through the target's file-level entry, and hold the invariants. The
// contract:
//
// - no panic, however malformed the source (a crash fails the target);
// - a rejected file yields a diagnostic and never a partial emission:
// the assembler returns a nil image beside its error, and the
// diagnostic is not empty;
// - the output is deterministic: the same source, parsed and assembled
// again from scratch, produces the same bytes;
// - no unbounded memory: the fence around every test run kills a run that
// amplifies its input, and the timebox turns a hang into a campaign
// failure to bisect.
//
// A file the parser rejects still reaches the assembler: the parser is
// line-oriented and tolerant, so it hands back a usable file either way, and
// the assembler's own contract is to answer any file it is given with bytes
// or with a diagnostic, never with a panic.
func fuzzAssemble(t *testing.T, name string, src string, assemble func(*ast.File) (*Image, error)) {
file, _ := parser.ParseWithOptions(name, src,
parser.Options{Expand: true, IncludeDirs: fuzzIncludeDirs})
if file == nil {
t.Fatal("ParseWithOptions returned a nil file")
}
img, err := assemble(file)
if err != nil {
if img != nil {
t.Fatal("the assembler returned an image beside its error: a rejected file must not emit")
}
if strings.TrimSpace(err.Error()) == "" {
t.Fatal("rejection carries an empty diagnostic")
}
return
}
// Determinism: a second assembly of the same file must produce the same
// bytes. One parse serves both runs, so any mutation the assembler makes
// to the syntax tree it was handed shows up as differing bytes, and the
// workers' footprint under the shared memory fence stays that of a single
// parse.
img2, err2 := assemble(file)
if err2 != nil {
t.Fatalf("the second assembly failed where the first succeeded: %v", err2)
}
if !bytes.Equal(img.Bytes(), img2.Bytes()) {
t.Fatal("the same source assembled to different bytes")
}
}
// FuzzAssembleAMD64 hammers the full parse-and-assemble pipeline for the
// fixed amd64 target with arbitrary source: expansion (macros and includes)
// included, matching the CLI's own pipeline.
func FuzzAssembleAMD64(f *testing.F) {
corpusSeeds(f)
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("TEXT ·f(SB), $16-8\n\tMOVQ x+0(FP), AX\n\tMOVQ AX, ret+8(FP)\n\tRET\n")
f.Add("TEXT ·f(SB), $256-0\n\tCALL ·helper(SB)\n\tRET\nTEXT ·helper(SB), NOSPLIT, $0\n\tRET\n")
f.Add("#define L(n) MOVQ $n, AX\nTEXT ·f(SB), NOSPLIT, $0\n\tL(7)\n\tRET\n")
f.Add("#include \"textflag.h\"\nTEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("#include \"fuzzdefs.h\"\nTEXT ·f(SB), $16-8\n\tMOVQ KONST, AX\n\tMOVQ ARG(x), BX\n\tRET\n")
f.Add("DATA d<>+0(SB)/8, $0xf4f8fcff\nDATA d<>+4(SB)/4, $1\nGLOBL d<>(SB), RODATA, $8\n" +
"TEXT ·f(SB), NOSPLIT, $0\n\tMOVQ d<>(SB), AX\n\tRET\n")
f.Add("DATA s+0(SB)/8, $\"hi there\"\nGLOBL s(SB), $8\nDATA p+0(SB)/8, $s(SB)\nGLOBL p(SB), $8\n")
f.Add("DATA d+0(SB)/8, $0xFFFFFFFFFFFFFFFF\nGLOBL d(SB), $8\n" +
"TEXT ·f(SB), $0-8\n\tMOVQ $0xFFFFFFFFFFFFFFFF, AX\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\nL1:\n\tMOVQ AX, BX\n\tJMP L1\n\tJMP -3(PC)\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tLOOP L1\nL1:\n\tLOOPE L1\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tJMP *AX\n\tCALL (BX)\n\tJMP (R12)(R8*4)\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tMOVQ TLS, AX\n\tMOVQ 8(AX)(TLS*1), BX\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tPCALIGN $16\n\tMOVQ AX, BX\n\tPCALIGN $32\n\tMOVQ AX, BX\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tADJSP $16\n\tMOVQ AX, -8(SP)\n\tADJSP $-16\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tADDSD $1.5, X0\n\tMULSD $(-1.0), X1\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tSHLL CX, R11:AX\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tPCDATA $0, $1\n\tFUNCDATA $0, ·meta(SB)\n\tRET\n")
f.Add("TEXT ·f(SB), $0\n\tCALL runtime·morestack_noctxt(SB)\n\tRET\n")
f.Add("TEXT ·f(SB), $32-0\n\tMOVQ AX, x-8(SP)\n\tMOVQ BX, x-16(SP)(CX*1)\n\tRET\n")
// Shapes that must be rejected: each pins a diagnostic path the seeds
// above never reach.
f.Add("TEXT ·f(SB), $0\n\tBOGUSINSTR AX, BX\n\tRET\n")
f.Add("GLOBL d(SB), $-8\n")
f.Add("GLOBL d(SB), $-1\n")
f.Add("GLOBL d(SB), $0x4000001\n")
f.Add("GLOBL d(SB), $0x7FFFFFFFFFFFFFFF\n")
f.Add("DATA d+0(SB)/9, $1\nGLOBL d(SB), $8\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tADJSP $16\n\tRET\n")
f.Add("#define A A\nA\n")
f.Fuzz(func(t *testing.T, src string) {
fuzzAssemble(t, "fuzz_amd64.s", src, func(f *ast.File) (*Image, error) {
return AssembleFile(f)
})
})
}
// FuzzAssembleARM64 hammers the same pipeline for the fixed arm64 target,
// whose encoder carries its own immediate classification, memory-offset
// gates and literal pool. The seeds pin the recent encoder families: the
// system registers and barriers, the LSE atomics and exclusive pairs, the
// NEON structure loads and stores, the ADDCON2 offset split, the pooled
// vector constants, and the macro and include expansion over arm64
// spellings. The rejection shapes pin the diagnostic paths the accepted
// seeds never reach.
func FuzzAssembleARM64(f *testing.F) {
corpusSeeds(f)
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("TEXT ·f(SB), $16-8\n\tMOVW x+0(FP), R0\n\tMOVW R0, ret+8(FP)\n\tRET\n")
f.Add("TEXT ·f(SB), $256-0\n\tCALL ·helper(SB)\n\tRET\nTEXT ·helper(SB), NOSPLIT, $0\n\tRET\n")
f.Add("#define L(n) MOVD $n, R0\nTEXT ·f(SB), NOSPLIT, $0\n\tL(7)\n\tRET\n")
f.Add("#include \"textflag.h\"\nTEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("#include \"fuzzdefs.h\"\nTEXT ·f(SB), $16-8\n\tMOVD KONST, R0\n\tMOVD ARG(x), R1\n\tRET\n")
f.Add("DATA d<>+0(SB)/8, $0xf4f8fcff\nDATA d<>+4(SB)/4, $1\nGLOBL d<>(SB), RODATA, $8\n" +
"TEXT ·f(SB), NOSPLIT, $0\n\tMOVD d<>(SB), R0\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tMRS DCZID_EL0, R3\n\tMRS CNTVCT_EL0, R0\n\tMSR $3, SPSel\n" +
"\tMSR $9, DAIFSet\n\tDMB $15\n\tDSB $4\n\tISB $1\n\tDC ZVA, R4\n\tSVC $0\n\tBRK $35943\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tLDADDB R2, (R1), R3\n\tLDADDD R2, (R1), ZR\n\tCASW R2, (R1), R3\n" +
"\tSWPD R2, (R1), ZR\n\tLDXR (R1), R2\n\tLDAXRW (R1), R5\n\tSTXR R2, (R1), R6\n\tSTLXRB R3, (R1), R6\n" +
"\tLDXP (R1), (R2, R3)\n\tSTXP (R2, R3), (R1), R6\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tVLD1 (R2), [V21.B16]\n\tVLD1.P 32(R1), [V2.B16, V3.B16]\n" +
"\tVLD1R (R0), [V0.B16]\n\tVST1 [V2.S4, V3.S4], (R14)\n\tVST1.P [V2.B16], (R1)\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tADD $0xaaaaaa, R2, R3\n\tSUB $0x186a0, R2, R3\n\tADDW $0x60060, R2\n\tCMP $40960, R0\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tVMOVD $0x123456789ABCDEF0, V0\n\tVMOVQ $0x12345678, $0x9ABCDEF0, V1\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0-8\n\tMOVW $-1, R0\n\tB after1\n\tMOVD $0x0001000200030004, R1\n" +
"after1:\n\tMOVD R1, ret+0(FP)\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\nL1:\n\tCBZ R1, L1\n\tTBZ $3, R2, L1\n\tBEQ L1\n\tJMP L1\n\tCALL (R5)\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tPCALIGN $16\n\tMOVD R0, R1\n\tPCALIGN $32\n\tRET\n")
f.Add("TEXT ·f(SB), $0\n\tPCDATA $0, $1\n\tFUNCDATA $0, ·meta(SB)\n\tRET\n")
f.Add("TEXT ·f(SB), $32-0\n\tMOVD R0, x-8(SP)\n\tRET\n")
// Shapes that must be rejected: each pins a diagnostic path the seeds
// above never reach.
f.Add("TEXT ·f(SB), $0\n\tBOGUSINSTR R0, R1\n\tRET\n")
f.Add("GLOBL d(SB), $-8\n")
f.Add("GLOBL d(SB), $0x4000001\n")
f.Add("DATA d+0(SB)/9, $1\nGLOBL d(SB), $8\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tADD R1, X99\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tMOVD $1, R1\n\tMOVD 0x1000000(R1), R2\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tVLD1 (R2), [V21.B17]\n\tRET\n")
f.Add("#define A A\nA\n")
f.Fuzz(func(t *testing.T, src string) {
fuzzAssemble(t, "fuzz_arm64.s", src, AssembleFileARM64)
})
}
// FuzzAssembleRISCV64 hammers the same pipeline for the fixed riscv64 target.
// The seed corpus lives in testdata/seeds/riscv64: the instruction families
// GOROOT's riscv64 assembler corpus exercises (the immediate-range ladder of
// the I-type arithmetic, the load/store and branch offsets, the atomics, the
// FP conversions and the fused multiply-adds), the RVV configuration and
// arithmetic classes with their mask forms, the RVC-compressible shapes, the
// CSR instructions and the Zbb/Zba/Zbs bit-manipulation set, minimalised.
// The inline seeds below pin the shared file-level surface and the rejection
// shapes, one diagnostic path each.
func FuzzAssembleRISCV64(f *testing.F) {
corpusSeeds(f)
archCorpusSeeds(f, "riscv64")
// Minimal seeds for the shared file-level surface, spelled the riscv64
// way: the guard classes, the macro and include expansion, and the data
// section with its symbol-valued fields.
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("TEXT ·f(SB), $16-8\n\tMOV x+0(FP), X10\n\tMOV X10, ret+8(FP)\n\tRET\n")
f.Add("TEXT ·f(SB), $256-0\n\tCALL ·helper(SB)\n\tRET\nTEXT ·helper(SB), NOSPLIT, $0\n\tRET\n")
f.Add("#define L(n) ADDI $n, X10, X10\nTEXT ·f(SB), NOSPLIT, $0\n\tL(7)\n\tRET\n")
f.Add("#include \"textflag.h\"\nTEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("#include \"fuzzdefs.h\"\nTEXT ·f(SB), $16-8\n\tMOV KONST, X10\n\tMOV ARG(x), X11\n\tRET\n")
f.Add("DATA d<>+0(SB)/8, $0xf4f8fcff\nDATA d<>+4(SB)/4, $1\nGLOBL d<>(SB), RODATA, $8\n" +
"TEXT ·f(SB), NOSPLIT, $0\n\tMOV d<>(SB), X10\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tPCALIGN $16\n\tADD X11, X10, X10\n\tPCALIGN $2048\n\tRET\n")
f.Add("TEXT ·f(SB), $0\n\tPCDATA $0, $1\n\tFUNCDATA $0, ·meta(SB)\n\tRET\n")
// Shapes that must be rejected: each pins a diagnostic path the accepted
// seeds never reach.
f.Add("TEXT ·f(SB), $0\n\tBOGUSINSTR X10, X11\n\tRET\n")
f.Add("GLOBL d(SB), $-8\n")
f.Add("GLOBL d(SB), $0x4000001\n")
f.Add("DATA d+0(SB)/9, $1\nGLOBL d(SB), $8\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tADD X11, X32\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tADDI $2048, X5, X6\n\tADD X11, X40, X6\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tCSRRW $0x1000, X5, X6\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tSLLI $64, X5, X6\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tVLE8V (X10), V32\n\tRET\n")
// Operand-starved spellings that used to panic the layout and encode
// passes; each must come back as a diagnostic.
f.Add("TEXT ·f(SB), $0\n\tJALR\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tROR $3\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tVLE8V (X10)\n\tRET\n")
f.Add("#define A A\nA\n")
f.Fuzz(func(t *testing.T, src string) {
fuzzAssemble(t, "fuzz_riscv64.s", src, AssembleFileRISCV)
})
}
// FuzzAssembleLOONG64 hammers the same pipeline for the fixed loong64 target.
// The seed corpus lives in testdata/seeds/loong64: the LSX and LASX register
// banks with their immediate forms and range gates (the si5 compares, the
// biased shifts, the VSHUF4I/VPERMI/VEXTRINS immediates), the ll/sc offset
// ladder with its three encoding spans, the pointer loads and stores, the
// atomics with their dbar forms, the branches, the bit-field instructions and
// the register-class moves, minimalised. The inline seeds below pin the
// shared file-level surface and the rejection shapes, one diagnostic path
// each.
func FuzzAssembleLOONG64(f *testing.F) {
corpusSeeds(f)
archCorpusSeeds(f, "loong64")
// Minimal seeds for the shared file-level surface, spelled the loong64
// way.
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("TEXT ·f(SB), $16-8\n\tMOVV x+0(FP), R4\n\tMOVV R4, ret+8(FP)\n\tRET\n")
f.Add("TEXT ·f(SB), $256-0\n\tCALL ·helper(SB)\n\tRET\nTEXT ·helper(SB), NOSPLIT, $0\n\tRET\n")
f.Add("#define L(n) ADDV $n, R4, R4\nTEXT ·f(SB), NOSPLIT, $0\n\tL(7)\n\tRET\n")
f.Add("#include \"textflag.h\"\nTEXT ·f(SB), NOSPLIT, $0\n\tRET\n")
f.Add("#include \"fuzzdefs.h\"\nTEXT ·f(SB), $16-8\n\tMOVV KONST, R4\n\tMOVV ARG(x), R5\n\tRET\n")
f.Add("DATA d<>+0(SB)/8, $0xf4f8fcff\nDATA d<>+4(SB)/4, $1\nGLOBL d<>(SB), RODATA, $8\n" +
"TEXT ·f(SB), NOSPLIT, $0\n\tMOVV d<>(SB), R4\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tPCALIGN $16\n\tADDV R5, R4, R4\n\tPCALIGN $2048\n\tRET\n")
f.Add("TEXT ·f(SB), $0\n\tPCDATA $0, $1\n\tFUNCDATA $0, ·meta(SB)\n\tRET\n")
// Shapes that must be rejected: each pins a diagnostic path the accepted
// seeds never reach.
f.Add("TEXT ·f(SB), $0\n\tBOGUSINSTR R4, R5\n\tRET\n")
f.Add("GLOBL d(SB), $-8\n")
f.Add("GLOBL d(SB), $0x4000001\n")
f.Add("DATA d+0(SB)/9, $1\nGLOBL d(SB), $8\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tADD R5, R32\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tBEQZ V0, L1\nL1:\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tVADDV V1, V2, X3\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tVSEQV $32, V2, V3\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tVSHUF4IV $16, V2, V1\n\tRET\n")
f.Add("TEXT ·f(SB), NOSPLIT, $0\n\tBSTRPICKV $64, R4, $5, R6\n\tRET\n")
f.Add("#define A A\nA\n")
f.Fuzz(func(t *testing.T, src string) {
fuzzAssemble(t, "fuzz_loong64.s", src, AssembleFileLOONG64)
})
}