feat(go): add core go fuzz command and wire into QA
- New `core go fuzz` command discovers Fuzz* targets and runs them with configurable --duration (default 10s per target) - Fuzz added to default QA checks with 5s burst duration - Seed fuzz targets for core package: FuzzE (error constructor), FuzzServiceRegistration, FuzzMessageDispatch Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
192769ea69
commit
0f40b99ba0
4 changed files with 291 additions and 3 deletions
169
internal/cmd/go/cmd_fuzz.go
Normal file
169
internal/cmd/go/cmd_fuzz.go
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
package gocmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
"github.com/host-uk/core/pkg/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
fuzzDuration time.Duration
|
||||||
|
fuzzPkg string
|
||||||
|
fuzzRun string
|
||||||
|
fuzzVerbose bool
|
||||||
|
)
|
||||||
|
|
||||||
|
func addGoFuzzCommand(parent *cli.Command) {
|
||||||
|
fuzzCmd := &cli.Command{
|
||||||
|
Use: "fuzz",
|
||||||
|
Short: "Run Go fuzz tests",
|
||||||
|
Long: `Run Go fuzz tests with configurable duration.
|
||||||
|
|
||||||
|
Discovers Fuzz* functions across the project and runs each with go test -fuzz.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
core go fuzz # Run all fuzz targets for 10s each
|
||||||
|
core go fuzz --duration=30s # Run each target for 30s
|
||||||
|
core go fuzz --pkg=./pkg/... # Fuzz specific package
|
||||||
|
core go fuzz --run=FuzzE # Run only matching fuzz targets`,
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
return runGoFuzz(fuzzDuration, fuzzPkg, fuzzRun, fuzzVerbose)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzzCmd.Flags().DurationVar(&fuzzDuration, "duration", 10*time.Second, "Duration per fuzz target")
|
||||||
|
fuzzCmd.Flags().StringVar(&fuzzPkg, "pkg", "", "Package to fuzz (default: auto-discover)")
|
||||||
|
fuzzCmd.Flags().StringVar(&fuzzRun, "run", "", "Only run fuzz targets matching pattern")
|
||||||
|
fuzzCmd.Flags().BoolVarP(&fuzzVerbose, "verbose", "v", false, "Verbose output")
|
||||||
|
|
||||||
|
parent.AddCommand(fuzzCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzTarget represents a discovered fuzz function and its package.
|
||||||
|
type fuzzTarget struct {
|
||||||
|
Pkg string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGoFuzz(duration time.Duration, pkg, run string, verbose bool) error {
|
||||||
|
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fuzz")), i18n.ProgressSubject("run", "fuzz tests"))
|
||||||
|
cli.Blank()
|
||||||
|
|
||||||
|
targets, err := discoverFuzzTargets(pkg, run)
|
||||||
|
if err != nil {
|
||||||
|
return cli.Wrap(err, "discover fuzz targets")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(targets) == 0 {
|
||||||
|
cli.Print(" %s no fuzz targets found\n", dimStyle.Render("—"))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Print(" %s %d target(s), %s each\n", dimStyle.Render(i18n.Label("targets")), len(targets), duration)
|
||||||
|
cli.Blank()
|
||||||
|
|
||||||
|
passed := 0
|
||||||
|
failed := 0
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
cli.Print(" %s %s in %s\n", dimStyle.Render("→"), t.Name, t.Pkg)
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"test",
|
||||||
|
fmt.Sprintf("-fuzz=^%s$", t.Name),
|
||||||
|
fmt.Sprintf("-fuzztime=%s", duration),
|
||||||
|
"-run=^$", // Don't run unit tests
|
||||||
|
}
|
||||||
|
if verbose {
|
||||||
|
args = append(args, "-v")
|
||||||
|
}
|
||||||
|
args = append(args, t.Pkg)
|
||||||
|
|
||||||
|
cmd := exec.Command("go", args...)
|
||||||
|
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
|
||||||
|
cmd.Dir, _ = os.Getwd()
|
||||||
|
|
||||||
|
output, runErr := cmd.CombinedOutput()
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
if runErr != nil {
|
||||||
|
failed++
|
||||||
|
cli.Print(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), runErr.Error())
|
||||||
|
if outputStr != "" {
|
||||||
|
cli.Text(outputStr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
passed++
|
||||||
|
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
||||||
|
if verbose && outputStr != "" {
|
||||||
|
cli.Text(outputStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
if failed > 0 {
|
||||||
|
cli.Print("%s %d passed, %d failed\n", errorStyle.Render(cli.Glyph(":cross:")), passed, failed)
|
||||||
|
return cli.Err("fuzz: %d target(s) failed", failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Print("%s %d passed\n", successStyle.Render(cli.Glyph(":check:")), passed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverFuzzTargets scans for Fuzz* functions in test files.
|
||||||
|
func discoverFuzzTargets(pkg, pattern string) ([]fuzzTarget, error) {
|
||||||
|
root := "."
|
||||||
|
if pkg != "" {
|
||||||
|
// Convert Go package pattern to filesystem path
|
||||||
|
root = strings.TrimPrefix(pkg, "./")
|
||||||
|
root = strings.TrimSuffix(root, "/...")
|
||||||
|
}
|
||||||
|
|
||||||
|
fuzzRe := regexp.MustCompile(`^func\s+(Fuzz\w+)\s*\(\s*\w+\s+\*testing\.F\s*\)`)
|
||||||
|
var matchRe *regexp.Regexp
|
||||||
|
if pattern != "" {
|
||||||
|
var err error
|
||||||
|
matchRe, err = regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid --run pattern: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets []fuzzTarget
|
||||||
|
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() || !strings.HasSuffix(info.Name(), "_test.go") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, readErr := os.ReadFile(path)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := "./" + filepath.Dir(path)
|
||||||
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
|
m := fuzzRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := m[1]
|
||||||
|
if matchRe != nil && !matchRe.MatchString(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, fuzzTarget{Pkg: dir, Name: name})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return targets, err
|
||||||
|
}
|
||||||
|
|
@ -32,4 +32,5 @@ func AddGoCommands(root *cli.Command) {
|
||||||
addGoInstallCommand(goCmd)
|
addGoInstallCommand(goCmd)
|
||||||
addGoModCommand(goCmd)
|
addGoModCommand(goCmd)
|
||||||
addGoWorkCommand(goCmd)
|
addGoWorkCommand(goCmd)
|
||||||
|
addGoFuzzCommand(goCmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func addGoQACommand(parent *cli.Command) {
|
||||||
Short: "Run QA checks",
|
Short: "Run QA checks",
|
||||||
Long: `Run comprehensive code quality checks for Go projects.
|
Long: `Run comprehensive code quality checks for Go projects.
|
||||||
|
|
||||||
Checks available: fmt, vet, lint, test, race, vuln, sec, bench, docblock
|
Checks available: fmt, vet, lint, test, race, fuzz, vuln, sec, bench, docblock
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
core go qa # Default: fmt, lint, test
|
core go qa # Default: fmt, lint, test
|
||||||
|
|
@ -64,7 +64,7 @@ Examples:
|
||||||
// Scope flags
|
// Scope flags
|
||||||
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
|
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
|
||||||
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
|
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
|
||||||
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,vuln,sec,bench)")
|
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,fuzz,vuln,sec,bench)")
|
||||||
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
|
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
|
||||||
|
|
||||||
// Coverage flags
|
// Coverage flags
|
||||||
|
|
@ -313,7 +313,7 @@ func determineChecks() []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default checks
|
// Default checks
|
||||||
checks := []string{"fmt", "lint", "test", "docblock"}
|
checks := []string{"fmt", "lint", "test", "fuzz", "docblock"}
|
||||||
|
|
||||||
// Add race if requested
|
// Add race if requested
|
||||||
if qaRace {
|
if qaRace {
|
||||||
|
|
@ -424,6 +424,9 @@ func buildCheck(name string) QACheck {
|
||||||
case "sec":
|
case "sec":
|
||||||
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
|
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
|
||||||
|
|
||||||
|
case "fuzz":
|
||||||
|
return QACheck{Name: "fuzz", Command: "_internal_"}
|
||||||
|
|
||||||
case "docblock":
|
case "docblock":
|
||||||
// Special internal check - handled separately
|
// Special internal check - handled separately
|
||||||
return QACheck{Name: "docblock", Command: "_internal_"}
|
return QACheck{Name: "docblock", Command: "_internal_"}
|
||||||
|
|
@ -524,6 +527,14 @@ func runCoverage(ctx context.Context, dir string) (float64, error) {
|
||||||
// runInternalCheck runs internal Go-based checks (not external commands).
|
// runInternalCheck runs internal Go-based checks (not external commands).
|
||||||
func runInternalCheck(check QACheck) (string, error) {
|
func runInternalCheck(check QACheck) (string, error) {
|
||||||
switch check.Name {
|
switch check.Name {
|
||||||
|
case "fuzz":
|
||||||
|
// Short burst fuzz in QA (5s per target)
|
||||||
|
duration := 5 * time.Second
|
||||||
|
if qaTimeout > 0 && qaTimeout < 30*time.Second {
|
||||||
|
duration = 2 * time.Second
|
||||||
|
}
|
||||||
|
return "", runGoFuzz(duration, "", "", qaVerbose)
|
||||||
|
|
||||||
case "docblock":
|
case "docblock":
|
||||||
result, err := qa.CheckDocblockCoverage([]string{"./..."})
|
result, err := qa.CheckDocblockCoverage([]string{"./..."})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
107
pkg/framework/core/fuzz_test.go
Normal file
107
pkg/framework/core/fuzz_test.go
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FuzzE exercises the E() error constructor with arbitrary input.
|
||||||
|
func FuzzE(f *testing.F) {
|
||||||
|
f.Add("svc.Method", "something broke", true)
|
||||||
|
f.Add("", "", false)
|
||||||
|
f.Add("a.b.c.d.e.f", "unicode: \u00e9\u00e8\u00ea", true)
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, op, msg string, withErr bool) {
|
||||||
|
var underlying error
|
||||||
|
if withErr {
|
||||||
|
underlying = errors.New("wrapped")
|
||||||
|
}
|
||||||
|
|
||||||
|
e := E(op, msg, underlying)
|
||||||
|
if e == nil {
|
||||||
|
t.Fatal("E() returned nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
s := e.Error()
|
||||||
|
if s == "" {
|
||||||
|
t.Fatal("Error() returned empty string")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip: Unwrap should return the underlying error
|
||||||
|
var coreErr *Error
|
||||||
|
if !errors.As(e, &coreErr) {
|
||||||
|
t.Fatal("errors.As failed for *Error")
|
||||||
|
}
|
||||||
|
if withErr && coreErr.Unwrap() == nil {
|
||||||
|
t.Fatal("Unwrap() returned nil with underlying error")
|
||||||
|
}
|
||||||
|
if !withErr && coreErr.Unwrap() != nil {
|
||||||
|
t.Fatal("Unwrap() returned non-nil without underlying error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzServiceRegistration exercises service name registration with arbitrary names.
|
||||||
|
func FuzzServiceRegistration(f *testing.F) {
|
||||||
|
f.Add("myservice")
|
||||||
|
f.Add("")
|
||||||
|
f.Add("a/b/c")
|
||||||
|
f.Add("service with spaces")
|
||||||
|
f.Add("service\x00null")
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, name string) {
|
||||||
|
sm := newServiceManager()
|
||||||
|
|
||||||
|
err := sm.registerService(name, struct{}{})
|
||||||
|
if name == "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty name")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for name %q: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve should return the same service
|
||||||
|
got := sm.service(name)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatalf("service %q not found after registration", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate registration should fail
|
||||||
|
err = sm.registerService(name, struct{}{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected duplicate error for name %q", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FuzzMessageDispatch exercises action dispatch with concurrent registrations.
|
||||||
|
func FuzzMessageDispatch(f *testing.F) {
|
||||||
|
f.Add("hello")
|
||||||
|
f.Add("")
|
||||||
|
f.Add("test\nmultiline")
|
||||||
|
|
||||||
|
f.Fuzz(func(t *testing.T, payload string) {
|
||||||
|
c := &Core{
|
||||||
|
Features: &Features{},
|
||||||
|
svc: newServiceManager(),
|
||||||
|
}
|
||||||
|
c.bus = newMessageBus(c)
|
||||||
|
|
||||||
|
var received string
|
||||||
|
c.bus.registerAction(func(_ *Core, msg Message) error {
|
||||||
|
received = msg.(string)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
err := c.bus.action(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("action dispatch failed: %v", err)
|
||||||
|
}
|
||||||
|
if received != payload {
|
||||||
|
t.Fatalf("got %q, want %q", received, payload)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue