diff --git a/internal/cmd/go/cmd_fuzz.go b/internal/cmd/go/cmd_fuzz.go new file mode 100644 index 00000000..194cd1e1 --- /dev/null +++ b/internal/cmd/go/cmd_fuzz.go @@ -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 +} diff --git a/internal/cmd/go/cmd_go.go b/internal/cmd/go/cmd_go.go index 7aebd9f0..1fc7e463 100644 --- a/internal/cmd/go/cmd_go.go +++ b/internal/cmd/go/cmd_go.go @@ -32,4 +32,5 @@ func AddGoCommands(root *cli.Command) { addGoInstallCommand(goCmd) addGoModCommand(goCmd) addGoWorkCommand(goCmd) + addGoFuzzCommand(goCmd) } diff --git a/internal/cmd/go/cmd_qa.go b/internal/cmd/go/cmd_qa.go index 910852f6..2ac1dfc5 100644 --- a/internal/cmd/go/cmd_qa.go +++ b/internal/cmd/go/cmd_qa.go @@ -42,7 +42,7 @@ func addGoQACommand(parent *cli.Command) { Short: "Run QA checks", 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: core go qa # Default: fmt, lint, test @@ -64,7 +64,7 @@ Examples: // Scope flags 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().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)") // Coverage flags @@ -313,7 +313,7 @@ func determineChecks() []string { } // Default checks - checks := []string{"fmt", "lint", "test", "docblock"} + checks := []string{"fmt", "lint", "test", "fuzz", "docblock"} // Add race if requested if qaRace { @@ -424,6 +424,9 @@ func buildCheck(name string) QACheck { case "sec": return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}} + case "fuzz": + return QACheck{Name: "fuzz", Command: "_internal_"} + case "docblock": // Special internal check - handled separately 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). func runInternalCheck(check QACheck) (string, error) { 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": result, err := qa.CheckDocblockCoverage([]string{"./..."}) if err != nil { diff --git a/pkg/framework/core/fuzz_test.go b/pkg/framework/core/fuzz_test.go new file mode 100644 index 00000000..93972e0d --- /dev/null +++ b/pkg/framework/core/fuzz_test.go @@ -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) + } + }) +}