diff --git a/CHANGELOG.md b/CHANGELOG.md index 706dd10..1435fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ Unreleased changes on the `development` branch. - **`gasm diff --map`** — compare functions whose names differ between files (e.g. `--map wideCopyAVX2=wideCopyAVX512` pairs AVX2 and AVX-512 variants regardless of suffix). Unmapped functions fall back to the original name match. +- **`gasm verify --call`** — invoke a single function with user-supplied buffers + (`--buf name:size:pattern`) instead of the smoke/abi/fuzz sweeps. Patterns: + `zero`, `ones`, `seq`, or a hex blob. Useful for partial functions (e.g. + decoders) that crash on random input but should succeed on valid data. + The arg block is printed before and after the call, showing return values. +- **`gasm verify --ground-truth`** now documented in `--help` (was already a flag, + just missing from the help text). + +### Fixed + +- **Signature parser** — grouped Go parameters like `dst, src []byte` are now + parsed correctly (both get type `[]byte`). Previously the first name was + treated as its own type (`dst` with size 8), causing wrong ABI0 arg-block + layout in both `verify --call` and the fuzzer. ## [0.29.0] — 2026-08-05 diff --git a/cmd/gasm/main.go b/cmd/gasm/main.go index 06ace7a..9f12951 100644 --- a/cmd/gasm/main.go +++ b/cmd/gasm/main.go @@ -823,7 +823,7 @@ func cmdVerifyRISCV(path string, groundTruth, profile bool) int { } func cmdVerify(args []string) int { - fs := newCommand("verify", "gasm verify [-smoke] [-abi] [-profile] ", ` + fs := newCommand("verify", "gasm verify [-smoke] [-abi] [-fuzz] [-ground-truth] [-profile] [-call] ", ` Assemble FILE (amd64), map it into executable memory and report the available functions. This confirms the assembled image is self-consistent (no unresolved external symbols) and executable — the prerequisite for dynamic @@ -836,7 +836,18 @@ that tolerate nil pointers and zero lengths in their arguments. With -abi, each function is called with sentinel values in the callee-saved registers (BP, R14) and a red-zone canary below SP; violations are reported. +With -fuzz, each function with a // func signature is differentially fuzzed +against the go-tool-asm version in a subprocess (so a crash on a partial +function is reported, not fatal). + +With -ground-truth, the assembled machine code is compared byte-for-byte +against go tool asm (relocation sites masked), reporting any encoding drift. + With -profile, the static basic-block structure is listed for each function. + +With -call, a single function is invoked with user-supplied buffers (-buf) +instead of the smoke/abi/fuzz sweeps. Useful for partial functions (e.g. +decoders) that crash on random input but should succeed on valid data. `) smoke := fs.Bool("smoke", false, "call each NOSPLIT function with zeroed args") abi := fs.Bool("abi", false, "run ABI-checking calls (sentinel registers + red zone)") @@ -846,9 +857,12 @@ With -profile, the static basic-block structure is listed for each function. fuzz := fs.Bool("fuzz", false, "differential fuzz: JIT both gasm and go-tool-asm versions, compare outputs") fuzzN := fs.Int("n", 1000, "number of fuzz iterations per function") fuzzOne := fs.String("fuzz-one", "", "") // hidden: fuzz a single function (subprocess mode) + call := fs.String("call", "", "call a single function with -buf instead of the sweeps") + bufSpec := fs.String("buf", "", "buffer spec for -call: name:size:pattern[,name:size:pattern] (zero, ones, seq, or hex)") + repeat := fs.Int("repeat", 1, "number of times to repeat a -call invocation") fs.Parse(args) if fs.NArg() != 1 { - fmt.Fprintln(os.Stderr, "usage: gasm verify [-smoke] [-abi] [-profile] ") + fmt.Fprintln(os.Stderr, "usage: gasm verify [-smoke] [-abi] [-fuzz] [-ground-truth] [-profile] [-call] ") return 2 } path := fs.Arg(0) @@ -874,6 +888,11 @@ With -profile, the static basic-block structure is listed for each function. fmt.Printf("%s: %d functions JIT-loaded\n", path, len(names)) rc := 0 + // Single-function call mode: invoke one function with user-supplied buffers. + if *call != "" { + return cmdVerifyCall(k, path, *call, *bufSpec, *repeat) + } + // Subprocess mode: fuzz a single function and exit. if *fuzzOne != "" { gt, err := verify.GroundTruth(path) @@ -1095,3 +1114,122 @@ func fuzzInSubprocess(path, funcName string, n int) string { } return strings.TrimSpace(string(out)) } + +// cmdVerifyCall implements `gasm verify --call [--buf spec] [--repeat n]`. +// It invokes a single function with user-supplied buffers and prints the arg +// block before and after the call, so the user can inspect return values and +// any output written to the buffers. +func cmdVerifyCall(k *verify.Kernel, path, funcName, bufSpec string, repeat int) int { + fl, err := k.Func(funcName) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) + return 1 + } + if !fl.NoSplit { + fmt.Fprintf(os.Stderr, "gasm verify: %s is not NOSPLIT (frame=%d); --call supports NOSPLIT functions only\n", funcName, fl.Frame) + return 1 + } + + // Parse the // func signature to lay out the argument block. + src, err := readSource(path) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) + return 1 + } + sig, ok := verify.ExtractFuncSig(src, funcName) + if !ok { + fmt.Fprintf(os.Stderr, "gasm verify: no // func signature found for %s\n", funcName) + return 1 + } + layout := verify.ArgLayout(sig) + + // Allocate the requested buffers (if any) and build the arg block. + specs, err := verify.ParseBufSpec(bufSpec) + if err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) + return 1 + } + var pool verify.BufPool + if err := pool.Alloc(specs); err != nil { + fmt.Fprintf(os.Stderr, "gasm verify: %v\n", err) + return 1 + } + defer pool.Close() + args := pool.BuildArgs(layout, fl.Args) + + fmt.Printf("%s: %d bytes, args=%d\n", funcName, fl.Size, fl.Args) + fmt.Printf(" signature: func %s(%s) %s\n", sig.Name, formatParams(sig.Params), formatResults(sig.Results)) + if len(specs) > 0 { + fmt.Printf(" buffers:\n") + for _, s := range specs { + fmt.Printf(" %s: %d bytes, pattern=%s\n", s.Name, s.Size, s.Pattern) + } + } + fmt.Printf(" args before: %s\n", hexDump(args)) + + rc := 0 + for i := 0; i < repeat; i++ { + out, err := k.CallFunc(funcName, args) + if err != nil { + fmt.Printf(" call %d: FAIL — %v\n", i+1, err) + rc = 1 + continue + } + if repeat == 1 { + fmt.Printf(" args after: %s\n", hexDump(out)) + } else if i == repeat-1 { + fmt.Printf(" args after %d calls: %s\n", repeat, hexDump(out)) + } + fmt.Printf(" call %d: OK\n", i+1) + } + return rc +} + +// formatParams renders a parameter list as "a []byte, b []byte". +func formatParams(ps []verify.Param) string { + var parts []string + for _, p := range ps { + if p.Name != "" { + parts = append(parts, p.Name+" "+p.Typ) + } else { + parts = append(parts, p.Typ) + } + } + return strings.Join(parts, ", ") +} + +// formatResults renders a result list as "(n int, code int)" or "int". +func formatResults(rs []verify.Param) string { + if len(rs) == 0 { + return "" + } + if len(rs) == 1 && rs[0].Name == "" { + return rs[0].Typ + } + return "(" + formatParams(rs) + ")" +} + +// hexDump returns a one-line hex dump of buf, truncated to 64 bytes. +func hexDump(buf []byte) string { + const max = 64 + n := len(buf) + if n > max { + n = max + } + var sb strings.Builder + for i := 0; i < n; i++ { + if i > 0 { + sb.WriteByte(' ') + } + fmt.Fprintf(&sb, "%02x", buf[i]) + } + return fmt.Sprintf("%s%s (%d bytes)", sb.String(), truncMark(len(buf), max), len(buf)) +} + +// truncMark returns "…" when the buffer is longer than max, else "". +func truncMark(n, max int) string { + if n > max { + return "…" + } + return "" +} diff --git a/verify/buffers.go b/verify/buffers.go new file mode 100644 index 0000000..b355d19 --- /dev/null +++ b/verify/buffers.go @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package verify + +import ( + "encoding/binary" + "encoding/hex" + "fmt" + "strings" + "unsafe" +) + +// BufSpec is one buffer allocation request parsed from the user's --buf spec. +type BufSpec struct { + Name string + Size int // declared slice length and capacity + Pattern string // "zero", "ones", "seq", or a hex blob +} + +// ParseBufSpec parses a "name:size:pattern[,name:size:pattern]" spec string +// into individual buffer specs. Empty input yields an empty slice. +func ParseBufSpec(spec string) ([]BufSpec, error) { + if spec == "" { + return nil, nil + } + var out []BufSpec + for _, part := range strings.Split(spec, ",") { + fields := strings.SplitN(part, ":", 3) + 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 { + return nil, fmt.Errorf("verify: invalid buffer size %q in %q", fields[1], part) + } + out = append(out, BufSpec{Name: fields[0], Size: size, Pattern: fields[2]}) + } + return out, nil +} + +// allocatedBuf is one live buffer in a pool. +type allocatedBuf struct { + spec BufSpec + data []byte // Size + safetyMargin bytes; the first Size are the live region +} + +// safetyMargin is the extra bytes allocated past the declared size so SIMD +// over-reads and functions that read slightly past len never touch unmapped +// memory. Matches the margin used by the fuzz generator. +const safetyMargin = 8192 + +// BufPool is a set of allocated buffers held alive for the duration of one or +// more calls. Buffers live on the Go heap (the JIT call is in-process); the +// pool keeps the backing slices referenced so the GC does not collect them +// before the call returns. +type BufPool struct { + bufs []allocatedBuf +} + +// Alloc allocates and fills the buffers described by specs. The returned +// pool must be kept alive until every call using it has returned. +func (p *BufPool) Alloc(specs []BufSpec) error { + for _, s := range specs { + data := make([]byte, s.Size+safetyMargin) + fillBuffer(data, s.Pattern) + p.bufs = append(p.bufs, allocatedBuf{spec: s, data: data}) + } + return nil +} + +// Close releases the pool. No-op for Go-heap buffers, but keeps the API +// symmetric with debug's mmap-backed pool. +func (p *BufPool) Close() { + p.bufs = nil +} + +// findByName returns the buffer with the given spec name, if any. +func (p *BufPool) findByName(name string) *allocatedBuf { + for i := range p.bufs { + if p.bufs[i].spec.Name == name { + return &p.bufs[i] + } + } + return nil +} + +// BuildArgs constructs an ABI0 argument block of argSize bytes for the given +// layout, placing each buffer's pointer/length/capacity at the matching +// parameter offset. Parameters whose names match a buffer spec get the +// buffer address; non-pointer parameters and unmatched pointers are zeroed. +// +// Matching is by exact name, then by prefix (a buffer named "src" matches a +// parameter named "src" or "srcBuf"), mirroring the debug allocator. +func (p *BufPool) BuildArgs(layout []ArgOffset, argSize int) []byte { + args := make([]byte, argSize) + for _, a := range layout { + if !a.IsPtr { + continue + } + buf := p.matchBuf(a.Name) + if buf == nil { + continue + } + if a.Offset+8 <= len(args) { + binary.LittleEndian.PutUint64(args[a.Offset:a.Offset+8], uint64(uintptr(unsafe.Pointer(&buf.data[0])))) + } + if strings.HasPrefix(a.Typ, "[]") && a.Offset+24 <= len(args) { + binary.LittleEndian.PutUint64(args[a.Offset+8:a.Offset+16], uint64(buf.spec.Size)) + binary.LittleEndian.PutUint64(args[a.Offset+16:a.Offset+24], uint64(buf.spec.Size)) + } + } + return args +} + +// matchBuf finds a buffer matching the parameter name (exact, then prefix). +func (p *BufPool) matchBuf(name string) *allocatedBuf { + if b := p.findByName(name); b != nil { + return b + } + for i := range p.bufs { + if strings.HasPrefix(name, p.bufs[i].spec.Name) { + return &p.bufs[i] + } + } + return nil +} + +// 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) { + switch pattern { + case "zero": + // Already zeroed by make. + case "ones": + for i := range buf { + buf[i] = 0xFF + } + case "seq": + for i := range buf { + 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)] + } + } + } +} diff --git a/verify/buffers_test.go b/verify/buffers_test.go new file mode 100644 index 0000000..5b0fbf9 --- /dev/null +++ b/verify/buffers_test.go @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) +// SPDX-License-Identifier: BSD-3-Clause + +package verify + +import ( + "testing" +) + +func TestParseParamsExported(t *testing.T) { + tests := []struct { + input string + names []string + types []string + isPtr []bool + }{ + { + input: "dst []byte, src []byte", + names: []string{"dst", "src"}, + types: []string{"[]byte", "[]byte"}, + isPtr: []bool{true, true}, + }, + { + input: "dst, src []byte", + names: []string{"dst", "src"}, + types: []string{"[]byte", "[]byte"}, + isPtr: []bool{true, true}, + }, + { + input: "a, b int", + names: []string{"a", "b"}, + types: []string{"int", "int"}, + isPtr: []bool{false, false}, + }, + { + input: "src []byte, dst []byte", + names: []string{"src", "dst"}, + types: []string{"[]byte", "[]byte"}, + isPtr: []bool{true, true}, + }, + { + input: "swin []int32, dstP []uint32, hist *[32]uint16", + names: []string{"swin", "dstP", "hist"}, + types: []string{"[]int32", "[]uint32", "*[32]uint16"}, + isPtr: []bool{true, true, true}, + }, + { + input: "n int, code int", + names: []string{"n", "code"}, + types: []string{"int", "int"}, + isPtr: []bool{false, false}, + }, + } + for _, tt := range tests { + params := parseParamsExported(tt.input) + if len(params) != len(tt.names) { + t.Errorf("parseParamsExported(%q): got %d params, want %d", tt.input, len(params), len(tt.names)) + continue + } + for i, p := range params { + if p.Name != tt.names[i] { + t.Errorf("parseParamsExported(%q)[%d].Name = %q, want %q", tt.input, i, p.Name, tt.names[i]) + } + if p.Typ != tt.types[i] { + t.Errorf("parseParamsExported(%q)[%d].Typ = %q, want %q", tt.input, i, p.Typ, tt.types[i]) + } + if p.IsPointer() != tt.isPtr[i] { + t.Errorf("parseParamsExported(%q)[%d].IsPointer() = %v, want %v", tt.input, i, p.IsPointer(), tt.isPtr[i]) + } + } + } +} + +func TestParseBufSpec(t *testing.T) { + t.Run("empty", func(t *testing.T) { + specs, err := ParseBufSpec("") + if err != nil || len(specs) != 0 { + t.Errorf("ParseBufSpec(\"\") = %v, %v; want nil, nil", specs, err) + } + }) + + t.Run("single", func(t *testing.T) { + specs, err := ParseBufSpec("dst:64:zero") + if err != nil { + t.Fatal(err) + } + if len(specs) != 1 || specs[0].Name != "dst" || specs[0].Size != 64 || specs[0].Pattern != "zero" { + t.Errorf("ParseBufSpec(\"dst:64:zero\") = %+v; want [{dst 64 zero}]", specs) + } + }) + + t.Run("multiple", func(t *testing.T) { + specs, err := ParseBufSpec("dst:64:zero,src:128:seq") + if err != nil { + t.Fatal(err) + } + if len(specs) != 2 { + t.Fatalf("got %d specs, want 2", len(specs)) + } + if specs[0].Name != "dst" || specs[1].Name != "src" { + t.Errorf("names = %s, %s; want dst, src", specs[0].Name, specs[1].Name) + } + }) + + t.Run("invalid", func(t *testing.T) { + _, err := ParseBufSpec("bad") + if err == nil { + t.Error("ParseBufSpec(\"bad\") should error") + } + }) + + t.Run("zero-size", func(t *testing.T) { + _, err := ParseBufSpec("dst:0:zero") + if err == nil { + t.Error("ParseBufSpec(\"dst:0:zero\") should error on zero size") + } + }) +} + +func TestBufPoolBuildArgs(t *testing.T) { + specs, err := ParseBufSpec("dst:64:seq,src:128:zero") + if err != nil { + t.Fatal(err) + } + var pool BufPool + if err := pool.Alloc(specs); err != nil { + t.Fatal(err) + } + defer pool.Close() + + // Layout for wideCopyAVX2(dst, src []byte): dst at 0, src at 24. + layout := []ArgOffset{ + {Name: "dst", Typ: "[]byte", Offset: 0, Size: 24, IsPtr: true}, + {Name: "src", Typ: "[]byte", Offset: 24, Size: 24, IsPtr: true}, + } + args := pool.BuildArgs(layout, 48) + + // dst.ptr should be non-zero. + if args[0] == 0 && args[1] == 0 && args[2] == 0 && args[3] == 0 { + t.Error("dst.ptr is zero; expected a buffer address") + } + // dst.len should be 64 (0x40). + if args[8] != 0x40 { + t.Errorf("dst.len = %d, want 64", args[8]) + } + // dst.cap should be 64. + if args[16] != 0x40 { + t.Errorf("dst.cap = %d, want 64", args[16]) + } + // src.ptr should be non-zero. + if args[24] == 0 && args[25] == 0 && args[26] == 0 && args[27] == 0 { + t.Error("src.ptr is zero; expected a buffer address") + } + // src.len should be 128 (0x80). + if args[32] != 0x80 { + t.Errorf("src.len = %d, want 128", args[32]) + } +} diff --git a/verify/fuzz.go b/verify/fuzz.go index 8727570..cf8614b 100644 --- a/verify/fuzz.go +++ b/verify/fuzz.go @@ -79,22 +79,39 @@ func parseParams(s string) []param { if s == "" { return nil } + fields := strings.Split(s, ",") + // First pass: extract the type from each field (if present). + types := make([]string, len(fields)) + for i, field := range fields { + parts := strings.Fields(strings.TrimSpace(field)) + if len(parts) >= 2 { + types[i] = parts[len(parts)-1] + } + } + // Propagate types backward: a field without a type inherits from the next + // field that has one (e.g. "dst" inherits "[]byte" from "src []byte"). + for i := range fields { + if types[i] == "" { + for j := i + 1; j < len(fields); j++ { + if types[j] != "" { + types[i] = types[j] + break + } + } + } + } var out []param - for _, field := range strings.Split(s, ",") { + for i, field := range fields { field = strings.TrimSpace(field) if field == "" { continue } parts := strings.Fields(field) - if len(parts) == 1 { - // Unnamed: "int" or "[]byte". + typ := types[i] + if typ == "" { out = append(out, param{typ: parts[0]}) } else { - // Named: "a []byte" or shared "a, b []int32" (handled by the - // comma split above — "a" alone means the type follows in the - // next field; this is a simplification that covers the common - // case where each param has its own type). - out = append(out, param{name: parts[0], typ: parts[1]}) + out = append(out, param{name: parts[0], typ: typ}) } } return out diff --git a/verify/sigparse.go b/verify/sigparse.go index 4b45ed6..c7d0c1d 100644 --- a/verify/sigparse.go +++ b/verify/sigparse.go @@ -44,25 +44,49 @@ func ParseFuncSig(comment string) (FuncSig, bool) { return sig, true } -// parseParamsExported splits "a []byte, b []int32" into typed parameters. +// parseParamsExported splits a parameter list like "a []byte, b []int32" or +// "dst, src []byte" into typed parameters. Go syntax allows grouped names +// where the type at the end applies to every name in the group:// "dst, src []byte" means both dst and src are []byte. func parseParamsExported(s string) []Param { s = strings.TrimSpace(s) if s == "" { return nil } + fields := strings.Split(s, ",") + // First pass: extract the type from each field (if present). + types := make([]string, len(fields)) + for i, field := range fields { + parts := strings.Fields(strings.TrimSpace(field)) + if len(parts) >= 2 { + types[i] = parts[len(parts)-1] + } + } + // Propagate types backward: a field without a type inherits the type from + // the next field that has one (e.g. "dst" inherits "[]byte" from "src []byte"). + for i := range fields { + if types[i] == "" { + for j := i + 1; j < len(fields); j++ { + if types[j] != "" { + types[i] = types[j] + break + } + } + } + } + // Second pass: build params. var out []Param - for _, field := range strings.Split(s, ",") { + for i, field := range fields { field = strings.TrimSpace(field) if field == "" { continue } parts := strings.Fields(field) - if len(parts) == 1 { - // Unnamed: "int" or "[]byte". - out = append(out, Param{Typ: parts[0]}) + typ := types[i] + if typ == "" { + typ = parts[0] // unnamed: the whole field is the type + out = append(out, Param{Typ: typ}) } else { - // Named: "a []byte". - out = append(out, Param{Name: parts[0], Typ: parts[1]}) + out = append(out, Param{Name: parts[0], Typ: typ}) } } return out