From 14de1dd28734c4366531a2edd916b805325fb6b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Tue, 6 Oct 2026 23:50:40 +0200 Subject: [PATCH] test(asm): fuzz the parse-and-assemble pipeline for amd64 Assisted-by: GLM 5.3 Flash --- asm/assembler_fuzz_test.go | 121 ++++++++++++++++++++++++++++++++ asm/testdata/include/fuzzdefs.h | 5 ++ 2 files changed, 126 insertions(+) create mode 100644 asm/assembler_fuzz_test.go create mode 100644 asm/testdata/include/fuzzdefs.h diff --git a/asm/assembler_fuzz_test.go b/asm/assembler_fuzz_test.go new file mode 100644 index 0000000..366f047 --- /dev/null +++ b/asm/assembler_fuzz_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package asm + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "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 a fuzz target with the repository's kernels, so a plain +// `go test` run replays every seed as a regression case and CI exercises them +// without any fuzzing budget. The non-amd64 kernels exercise the rejection +// path (the fixed amd64 target reports them as diagnostics); the amd64 ones +// reach the 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)) + } + } + } +} + +// 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. The contract: +// +// - no panic, however malformed the source (a crash fails the target); +// - a rejected file yields a diagnostic and never a partial emission: +// AssembleFile 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 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("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) { + file, _ := parser.ParseWithOptions("fuzz_amd64.s", src, + parser.Options{Expand: true, IncludeDirs: fuzzIncludeDirs}) + if file == nil { + t.Fatal("ParseWithOptions returned a nil file") + } + img, err := AssembleFile(file) + if err != nil { + if img != nil { + t.Fatal("AssembleFile 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 parse-and-assemble from scratch must produce + // the same bytes, which also catches the assembler mutating the + // syntax tree it was handed. + file2, _ := parser.ParseWithOptions("fuzz_amd64.s", src, + parser.Options{Expand: true, IncludeDirs: fuzzIncludeDirs}) + if file2 == nil { + t.Fatal("the second ParseWithOptions returned a nil file") + } + img2, err2 := AssembleFile(file2) + 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") + } + }) +} diff --git a/asm/testdata/include/fuzzdefs.h b/asm/testdata/include/fuzzdefs.h new file mode 100644 index 0000000..a9ee7c7 --- /dev/null +++ b/asm/testdata/include/fuzzdefs.h @@ -0,0 +1,5 @@ +// Fixture for the assembler fuzz target: a constant macro and a +// parameterised one, so the corpus's #include seeds splice real +// definitions the way go_tls.h and textflag.h do in production sources. +#define KONST $42 +#define ARG(n) n+0(FP)