From 5ad61eb95aa6d55ba86fd7b617fc2ed43221c104 Mon Sep 17 00:00:00 2001 From: fOuttaMyPaint Date: Wed, 3 Jun 2026 21:11:33 -0400 Subject: [PATCH] PR1: subenum 0.5.1 patch (version, race fixes, TUI, build) Patch-only release. Fixes: - Data race in simulate mode via math/rand/v2 (goroutine-safe, auto-seeded). - Send-on-closed-channel race in scan.Run progress ticker. - TUI Aborted status on ctrl+c; form no longer blocks live mode on empty hit rate. - -version reports v0.5.1. - Docker build copies go.sum / go mod download; base image satisfies module Go version. - Go minimum reconciled to 1.24.2 across go.mod, Dockerfile, README, docs. Added tests: concurrent SimulateResolution, scan runner (concurrent + cancel), TUI form validation. CI: release workflow now also triggers on tag pushes (tags: ['v*']) so the first tag actually builds and publishes binaries. Docs: README facelift; ARCHITECTURE ticker interval (1s) and arg-parsing (cliFlags + flag.*Var) corrected; removed unused docs/assets/title.svg. CHANGELOG and ARCHITECTURE are scoped to 0.5.1 only (no unshipped features). Co-authored-by: Cursor --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/workflows/go.yml | 1 + .golangci.yml | 4 +- CHANGELOG.md | 21 +++++- Dockerfile | 8 +- README.md | 6 +- docs/ARCHITECTURE.md | 30 ++++---- docs/CONTRIBUTING.md | 2 +- docs/DEVELOPER_GUIDE.md | 2 +- docs/assets/title.svg | 16 ---- examples/advanced_usage.md | 2 +- go.mod | 9 ++- go.sum | 2 + internal/dns/simulate.go | 18 ++--- internal/dns/simulate_test.go | 22 ++++++ internal/scan/runner.go | 21 ++++-- internal/scan/runner_test.go | 107 +++++++++++++++++++++++++++ internal/tui/form.go | 11 ++- internal/tui/form_test.go | 69 +++++++++++++++++ internal/tui/model.go | 3 + main.go | 2 +- 21 files changed, 291 insertions(+), 67 deletions(-) delete mode 100644 docs/assets/title.svg create mode 100644 internal/scan/runner_test.go create mode 100644 internal/tui/form_test.go diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 329b2b3..5ec3e33 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -25,7 +25,7 @@ If applicable, add screenshots or copy the terminal output to help explain your **Environment (please complete the following information):** - OS: [e.g. Ubuntu 22.04, Windows 11, macOS 13.0] - - Go Version: [e.g. 1.22.0] + - Go Version: [e.g. 1.24.2] - subenum Version: [e.g. 0.3.0] **Additional context** diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a45a110..f8b5fd9 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -3,6 +3,7 @@ name: Go on: push: branches: [ "main" ] + tags: [ "v*" ] pull_request: branches: [ "main" ] diff --git a/.golangci.yml b/.golangci.yml index 57decfb..2362b78 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -15,8 +15,10 @@ linters: settings: gosec: excludes: - # G304: File path provided as taint input — expected for a wordlist tool + # G304: File path provided as taint input, expected for a wordlist tool - G304 + # G404: Weak RNG is fine; math/rand/v2 only drives simulation output, which is not security-sensitive + - G404 errcheck: # These write to stdout/files in hot paths; errors are intentionally ignored exclude-functions: diff --git a/CHANGELOG.md b/CHANGELOG.md index fd16017..432f065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.1] - 2026-06-03 + +### Fixed +- Data race in simulation mode: migrated `internal/dns/simulate.go` to `math/rand/v2`, whose top-level functions are goroutine-safe and auto-seeded. `SimulateResolution` is now safe to call concurrently (previously a shared `*math/rand.Rand` was used from every worker). +- Send-on-closed-channel race in `scan.Run`: the progress ticker goroutine now signals its own exit (`tickerStopped`) and guards its send with a select, and `Run` waits for that exit before emitting `EventDone`, so the deferred `close(events)` can no longer race an in-flight ticker send. +- TUI now renders the "Aborted" status when a scan is cancelled with `ctrl+c` (the scan view is marked aborted so the subsequent `EventDone` shows partial counts). +- TUI form no longer blocks a live-mode scan when the Hit Rate field is empty or out of range; hit rate is validated only when Simulate is on. +- `-version` now reports the correct version (`subenum v0.5.1`). +- Docker build: the builder now copies `go.sum` and runs `go mod download` before building, the base image satisfies the module Go version, and `main_test.go` is no longer copied into the build image. + +### Changed +- Go minimum version reconciled to 1.24.2 across `go.mod`, the Dockerfile base image, README, and docs (the charmbracelet TUI dependencies require it); direct vs indirect dependency classification corrected via `go mod tidy`. + +### Added +- Tests: concurrent `SimulateResolution` test, `internal/scan` runner tests (concurrent simulate run and mid-scan context cancellation), and `internal/tui` form validation tests. + +### Docs +- README facelift: plain-text description under the badges, a copy-paste quick-start block, the TUI screenshot promoted to a hero position, PRs-Welcome and platform badges, and removal of em dashes for a clean human-authored look. +- ARCHITECTURE: corrected the progress ticker interval (1 second) and the argument-parsing section (`flag.*Var` into a `cliFlags` struct). +- Removed the unused, duplicate `docs/assets/title.svg`. ## [0.5.0] - 2026-03-14 diff --git a/Dockerfile b/Dockerfile index 089bef1..8ea38ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,13 @@ -FROM golang:1.22-alpine AS builder +FROM golang:1.24.2-alpine AS builder WORKDIR /app -# Copy go.mod and go.sum first to leverage Docker cache -COPY go.mod ./ +# Copy module files first and download deps to leverage Docker layer caching +COPY go.mod go.sum ./ +RUN go mod download # Copy source code COPY main.go ./ -COPY main_test.go ./ COPY internal/ ./internal/ # Build the binary with optimizations diff --git a/README.md b/README.md index e5be828..3d564ea 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Build](https://img.shields.io/github/actions/workflow/status/TMHSDigital/subenum/go.yml?branch=main&style=for-the-badge&label=build)](https://github.com/TMHSDigital/subenum/actions) [![Release](https://img.shields.io/github/v/release/TMHSDigital/subenum?style=for-the-badge)](https://github.com/TMHSDigital/subenum/releases) -[![Go](https://img.shields.io/badge/Go-1.22+-00ADD8?style=for-the-badge&logo=go&logoColor=white)](https://go.dev) +[![Go](https://img.shields.io/badge/Go-1.24.2+-00ADD8?style=for-the-badge&logo=go&logoColor=white)](https://go.dev) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg?style=for-the-badge)](LICENSE) [![CodeQL](https://img.shields.io/github/actions/workflow/status/TMHSDigital/subenum/codeql.yml?label=CodeQL&style=for-the-badge)](https://github.com/TMHSDigital/subenum/actions/workflows/codeql.yml) [![Go Report Card](https://goreportcard.com/badge/github.com/TMHSDigital/subenum?style=for-the-badge&v=0.5.0)](https://goreportcard.com/report/github.com/TMHSDigital/subenum) @@ -87,7 +87,7 @@ flowchart LR ## Installation -**Prerequisites:** Go 1.22+ · Git · Make _(optional)_ · Docker _(optional)_ +**Prerequisites:** Go 1.24.2+ · Git · Make _(optional)_ · Docker _(optional)_
Build from source @@ -272,7 +272,7 @@ No flags required. Fill in the form and press `ctrl+r` to start scanning. Last-u | Layer | Components | | :--- | :--- | -| Core Engine | Go 1.22 · `net.Resolver` · `context` · `sync/atomic` | +| Core Engine | Go 1.24.2 · `net.Resolver` · `context` · `sync/atomic` | | Concurrency | goroutines · channels · `sync.WaitGroup` · `sync.Mutex` | | TUI | Bubble Tea · Bubbles (textinput, viewport, progress) · Lip Gloss | | Infrastructure | Docker · Alpine · Make · docker-compose | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6a51aca..9d8d6cd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,19 +40,19 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube ### 2.1. Argument Parsing * **Purpose**: This component is responsible for processing the command-line arguments provided by the user when `subenum` is executed. It extracts the target domain, the path to the wordlist file, the desired number of concurrent workers, and the DNS lookup timeout. -* **Implementation**: Utilizes Go's standard `flag` package. - * `flag.String("w", "", "Path to the wordlist file")`: Defines the wordlist file flag. - * `flag.Int("t", 100, "Number of concurrent workers")`: Defines the concurrency level flag. - * `flag.Int("timeout", 1000, "DNS lookup timeout in milliseconds")`: Defines the DNS timeout flag. - * `flag.String("dns-server", DefaultDNSServer, "DNS server to use")`: Defines the custom DNS server flag. - * `flag.Bool("v", false, "Enable verbose output")`: Defines the verbose flag. - * `flag.Bool("progress", true, "Show progress during scanning")`: Defines the progress reporting flag. - * `flag.Bool("version", false, "Show version information")`: Defines the version flag. - * `flag.String("o", "", "Write results to file")`: Defines the output file flag. - * `flag.Int("attempts", 0, "Total DNS resolution attempts per subdomain")`: Defines the attempt count flag. - * `flag.Int("retries", 0, "Deprecated: alias for -attempts")`: Deprecated retry flag. - * `flag.Bool("force", false, "Continue scanning on wildcard DNS")`: Defines the force flag. - * `flag.Parse()`: Parses the provided arguments. +* **Implementation**: Utilizes Go's standard `flag` package. `parseFlags()` binds every flag into a `cliFlags` struct using the `flag.*Var` forms, then calls `flag.Parse()`. + * `flag.StringVar(&f.wordlistFile, "w", "", ...)`: Binds the wordlist file flag. + * `flag.IntVar(&f.concurrency, "t", 100, ...)`: Binds the concurrency level flag. + * `flag.IntVar(&f.timeoutMs, "timeout", 1000, ...)`: Binds the DNS timeout flag. + * `flag.StringVar(&f.dnsServer, "dns-server", DefaultDNSServer, ...)`: Binds the custom DNS server flag. + * `flag.BoolVar(&f.verbose, "v", false, ...)`: Binds the verbose flag. + * `flag.BoolVar(&f.showProgress, "progress", true, ...)`: Binds the progress reporting flag. + * `flag.BoolVar(&f.showVersion, "version", false, ...)`: Binds the version flag. + * `flag.StringVar(&f.outputFile, "o", "", ...)`: Binds the output file flag. + * `flag.IntVar(&f.attempts, "attempts", 0, ...)`: Binds the attempt count flag. + * `flag.IntVar(&f.retries, "retries", 0, ...)`: Binds the deprecated retry flag. + * `flag.BoolVar(&f.force, "force", false, ...)`: Binds the force flag. + * `flag.Parse()`: Parses the provided arguments into the `cliFlags` struct. * `flag.Arg(0)`: Retrieves the positional argument (the target domain). * **Interactions**: The parsed values are used to configure the subsequent components, such as the Wordlist Processing and DNS Resolution Engine. Input validation is performed to ensure valid values for critical parameters like concurrency, timeout, DNS server format (validated via `validateDNSServer`), and domain syntax (validated via `validateDomain`). @@ -105,7 +105,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube * **Verbose Output** (when `-v` flag is enabled): * Configuration summary, per-query DNS resolution info, and final scan statistics — all via `Info` to stderr. * **Progress Reporting** (when `-progress` flag is enabled): - * A dedicated goroutine using a 2-second ticker calls `Progress` on stderr. + * A dedicated goroutine using a 1-second ticker calls `Progress` on stderr. * **Interactions**: All components route output through the `Writer`. Since results are the only thing on stdout, piping (`| cut -d' ' -f2`) works without `-progress=false`. ### 2.6. Progress Monitoring @@ -117,7 +117,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube * `processedWords`: An atomic counter that's incremented each time a subdomain is checked. * `foundSubdomains`: An atomic counter that's incremented each time a valid subdomain is found. * **Progress Display** (on stderr): - * A dedicated goroutine using a ticker (running every 2 seconds) calls `Writer.Progress` + * A dedicated goroutine using a ticker (running every 1 second) calls `Writer.Progress` * Uses `\r` carriage return to update the same line repeatedly * Shows percentage completion, processed count, and found count * **Interactions**: The Progress Monitoring component works alongside the worker goroutines, using atomic operations to safely track counts across multiple goroutines. Writing to stderr keeps stdout pipe-clean. diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 0be8154..7985416 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -13,7 +13,7 @@ See the [Code of Conduct](CODE_OF_CONDUCT.html). ### Prerequisites -- Go 1.22 or later +- Go 1.24.2 or later - Git - Make (optional but recommended) - Docker (optional, for containerized development) diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 9186a7c..151342c 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -13,7 +13,7 @@ This guide provides information for developers looking to contribute to or build To work with `subenum`, you'll need: -* **Go Programming Language**: [Go 1.22+](https://golang.org/dl/) is required. +* **Go Programming Language**: [Go 1.24.2+](https://golang.org/dl/) is required. * **Git**: For version control. * **Text Editor or IDE**: VS Code, GoLand, or any editor with Go support is recommended. diff --git a/docs/assets/title.svg b/docs/assets/title.svg deleted file mode 100644 index e257ecb..0000000 --- a/docs/assets/title.svg +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - $ subenum▌ - - - fast concurrent subdomain enumeration // written in Go - diff --git a/examples/advanced_usage.md b/examples/advanced_usage.md index d492bcb..da0e96a 100644 --- a/examples/advanced_usage.md +++ b/examples/advanced_usage.md @@ -157,7 +157,7 @@ For CI/CD environments, you can use the version flag to ensure the correct versi ```bash ./subenum -version -# Output: subenum v0.4.0 +# Output: subenum v0.5.1 ``` Use simulation mode in CI pipelines to test the tool's behaviour without network access: diff --git a/go.mod b/go.mod index 53a9546..dcd085c 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,17 @@ module github.com/TMHSDigital/subenum go 1.24.2 +require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/go.sum b/go.sum index 80498ca..41ee510 100644 --- a/go.sum +++ b/go.sum @@ -44,6 +44,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= diff --git a/internal/dns/simulate.go b/internal/dns/simulate.go index 078f3cd..78f5333 100644 --- a/internal/dns/simulate.go +++ b/internal/dns/simulate.go @@ -2,16 +2,12 @@ package dns import ( "fmt" - "math/rand" + "math/rand/v2" "os" "strings" "time" ) -// seededRand is a package-level random source seeded at startup. -// math/rand is appropriate here — simulation output is not security-sensitive. -var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) //nolint:gosec - // SimulateResolution returns a synthetic DNS result without performing any // network I/O. Common subdomain prefixes resolve ~90% of the time; everything // else uses the supplied hitRate (0-100). @@ -25,20 +21,20 @@ func SimulateResolution(domain string, hitRate int, verbose bool) bool { for _, sub := range commonSubdomains { if strings.HasPrefix(domain, sub+".") { if verbose { - fakeTiming := time.Duration(50+seededRand.Intn(200)) * time.Millisecond - fakeIP := fmt.Sprintf("192.168.%d.%d", seededRand.Intn(255), 1+seededRand.Intn(254)) + fakeTiming := time.Duration(50+rand.IntN(200)) * time.Millisecond + fakeIP := fmt.Sprintf("192.168.%d.%d", rand.IntN(255), 1+rand.IntN(254)) fmt.Fprintf(os.Stderr, "Resolved (SIMULATED): %s (IP: %s) in %s\n", domain, fakeIP, fakeTiming) } - return seededRand.Intn(100) < 90 + return rand.IntN(100) < 90 } } - result := seededRand.Intn(100) < hitRate + result := rand.IntN(100) < hitRate if verbose { - fakeTiming := time.Duration(100+seededRand.Intn(500)) * time.Millisecond + fakeTiming := time.Duration(100+rand.IntN(500)) * time.Millisecond if result { - fakeIP := fmt.Sprintf("10.%d.%d.%d", seededRand.Intn(255), seededRand.Intn(255), 1+seededRand.Intn(254)) + fakeIP := fmt.Sprintf("10.%d.%d.%d", rand.IntN(255), rand.IntN(255), 1+rand.IntN(254)) fmt.Fprintf(os.Stderr, "Resolved (SIMULATED): %s (IP: %s) in %s\n", domain, fakeIP, fakeTiming) } else { fmt.Fprintf(os.Stderr, "Failed to resolve (SIMULATED): %s (Error: no such host) in %s\n", domain, fakeTiming) diff --git a/internal/dns/simulate_test.go b/internal/dns/simulate_test.go index d5bb5ce..4b2f8ce 100644 --- a/internal/dns/simulate_test.go +++ b/internal/dns/simulate_test.go @@ -1,6 +1,7 @@ package dns import ( + "sync" "testing" ) @@ -27,3 +28,24 @@ func TestSimulateResolution(t *testing.T) { t.Errorf("Expected 0%% hit rate to never resolve, got %d/%d", resolved, runs) } } + +// TestSimulateResolutionConcurrent calls SimulateResolution from many goroutines +// at once. With math/rand/v2 top-level functions this is race-free; the test +// exists to be caught by `go test -race`. +func TestSimulateResolutionConcurrent(t *testing.T) { + const goroutines = 64 + const perGoroutine = 200 + + var wg sync.WaitGroup + for g := 0; g < goroutines; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < perGoroutine; i++ { + SimulateResolution("api.example.com", 50, false) + SimulateResolution("zzz-random.example.com", 25, true) + } + }() + } + wg.Wait() +} diff --git a/internal/scan/runner.go b/internal/scan/runner.go index 521282b..b1d870e 100644 --- a/internal/scan/runner.go +++ b/internal/scan/runner.go @@ -73,18 +73,27 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) { subdomains := make(chan string) var wg sync.WaitGroup - // Progress ticker — fires every second. - // tickerDone is closed to stop the goroutine before we close events. + // Progress ticker - fires every second. + // tickerDone signals the goroutine to stop; tickerStopped confirms it has + // fully exited so we never close events while a send is pending. tickerDone := make(chan struct{}) + tickerStopped := make(chan struct{}) ticker := time.NewTicker(time.Second) go func() { + defer close(tickerStopped) defer ticker.Stop() for { select { case <-ticker.C: p := atomic.LoadInt64(&processed) f := atomic.LoadInt64(&found) - events <- Event{Kind: EventProgress, Processed: p, Total: total, Found: f} + select { + case events <- Event{Kind: EventProgress, Processed: p, Total: total, Found: f}: + case <-tickerDone: + return + case <-ctx.Done(): + return + } case <-tickerDone: return case <-ctx.Done(): @@ -131,9 +140,11 @@ func Run(ctx context.Context, cfg Config, events chan<- Event) { drain: close(subdomains) wg.Wait() - // Stop the ticker goroutine before we close events, preventing a send on - // a closed channel if the ticker fires between wg.Wait() and defer close. + // Stop the ticker goroutine and wait for it to fully exit before emitting + // EventDone, so the deferred close(events) can never race an in-flight + // ticker send. close(tickerDone) + <-tickerStopped events <- Event{ Kind: EventDone, diff --git a/internal/scan/runner_test.go b/internal/scan/runner_test.go new file mode 100644 index 0000000..d6080b9 --- /dev/null +++ b/internal/scan/runner_test.go @@ -0,0 +1,107 @@ +package scan + +import ( + "context" + "testing" + "time" +) + +// makeEntries builds a synthetic wordlist of n prefixes. +func makeEntries(n int) []string { + entries := make([]string, n) + for i := range entries { + entries[i] = "p" + itoa(i) + } + return entries +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + var buf [20]byte + pos := len(buf) + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + return string(buf[pos:]) +} + +// TestRunSimulateConcurrent runs a simulate-mode scan with concurrency > 1 and +// asserts an EventDone arrives with Processed == Total. Under -race this also +// exercises the rand fix (simulate) and the ticker shutdown fix. +func TestRunSimulateConcurrent(t *testing.T) { + total := int64(2000) + cfg := Config{ + Domain: "example.com", + Entries: makeEntries(int(total)), + Concurrency: 16, + Timeout: time.Second, + Simulate: true, + HitRate: 50, + Attempts: 1, + } + + events := make(chan Event, 64) + go Run(context.Background(), cfg, events) + + var done *Event + for ev := range events { + if ev.Kind == EventDone { + e := ev + done = &e + } + } + + if done == nil { + t.Fatal("no EventDone received") + } + if done.Total != total { + t.Errorf("EventDone.Total = %d, want %d", done.Total, total) + } + if done.Processed != total { + t.Errorf("EventDone.Processed = %d, want %d", done.Processed, total) + } +} + +// TestRunContextCancel cancels mid-scan and asserts Run returns and closes the +// events channel promptly. +func TestRunContextCancel(t *testing.T) { + cfg := Config{ + Domain: "example.com", + Entries: makeEntries(100000), + Concurrency: 8, + Timeout: time.Second, + Simulate: true, + HitRate: 50, + Attempts: 1, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events := make(chan Event, 64) + go Run(ctx, cfg, events) + + // Consume a few events, then cancel. + received := 0 + for ev := range events { + received++ + if received == 1 || ev.Kind == EventProgress { + cancel() + } + if received > 20 { + cancel() + } + } + + // Reaching here means the channel was closed (Run returned) after cancel. + select { + case _, ok := <-events: + if ok { + t.Fatal("events channel still open after Run returned") + } + default: + } +} diff --git a/internal/tui/form.go b/internal/tui/form.go index e817d9f..e652936 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -291,9 +291,14 @@ func (m *formModel) validate() (formValues, string) { if err != nil || attempts < 1 { return formValues{}, fmt.Sprintf("Attempts must be >= 1, got %q", m.inputs[6].Value()) } - hitRate, err := strconv.Atoi(strings.TrimSpace(m.inputs[2].Value())) - if err != nil || hitRate < 1 || hitRate > 100 { - return formValues{}, fmt.Sprintf("Hit rate must be 1–100, got %q", m.inputs[2].Value()) + // Hit rate only matters in simulation mode; in live mode it is never used, + // so a blank or out-of-range field must not block the scan. + hitRate := 15 + if m.toggles[0] { + hitRate, err = strconv.Atoi(strings.TrimSpace(m.inputs[2].Value())) + if err != nil || hitRate < 1 || hitRate > 100 { + return formValues{}, fmt.Sprintf("Hit rate must be 1-100, got %q", m.inputs[2].Value()) + } } return formValues{ diff --git a/internal/tui/form_test.go b/internal/tui/form_test.go new file mode 100644 index 0000000..015d872 --- /dev/null +++ b/internal/tui/form_test.go @@ -0,0 +1,69 @@ +package tui + +import ( + "strings" + "testing" +) + +func TestNewFormModelDefaults(t *testing.T) { + m := newFormModel(savedConfig{}) + if len(m.inputs) != 7 { + t.Fatalf("expected 7 text inputs, got %d", len(m.inputs)) + } + if m.focus != fieldDomain { + t.Errorf("expected initial focus on domain, got %d", m.focus) + } + if got := m.inputs[1].Value(); got != "examples/sample_wordlist.txt" { + t.Errorf("default wordlist = %q", got) + } + if got := m.inputs[4].Value(); got != "100" { + t.Errorf("default concurrency = %q", got) + } +} + +func TestValidateMissingDomain(t *testing.T) { + m := newFormModel(savedConfig{}) + m.inputs[0].SetValue("") + if _, errStr := m.validate(); errStr == "" { + t.Error("expected validation error for missing domain") + } +} + +func TestValidateNonPositiveConcurrency(t *testing.T) { + m := newFormModel(savedConfig{}) + m.inputs[0].SetValue("example.com") + m.inputs[4].SetValue("0") + _, errStr := m.validate() + if !strings.Contains(errStr, "Concurrency") { + t.Errorf("expected concurrency error, got %q", errStr) + } +} + +// Empty hit rate must be accepted when Simulate is OFF (it is never used), +// and rejected when Simulate is ON. +func TestValidateHitRateOnlyWhenSimulate(t *testing.T) { + live := newFormModel(savedConfig{}) + live.inputs[0].SetValue("example.com") + live.inputs[2].SetValue("") // blank hit rate + live.toggles[0] = false + vals, errStr := live.validate() + if errStr != "" { + t.Errorf("live mode should ignore hit rate, got error %q", errStr) + } + if vals.hitRate < 1 || vals.hitRate > 100 { + t.Errorf("live mode should set a sane default hit rate, got %d", vals.hitRate) + } + + sim := newFormModel(savedConfig{}) + sim.inputs[0].SetValue("example.com") + sim.inputs[2].SetValue("") // blank hit rate + sim.toggles[0] = true + if _, errStr := sim.validate(); errStr == "" { + t.Error("simulate mode should reject blank hit rate") + } + + sim.inputs[2].SetValue("500") // out of range + if _, errStr := sim.validate(); errStr == "" { + t.Error("simulate mode should reject out-of-range hit rate") + } +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 64ee2cf..f88c5ec 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -142,6 +142,9 @@ func (m Model) updateScan(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cancel != nil { m.cancel() } + // Mark the scan as aborted so the upcoming doneMsg (scan.Run still + // drains and emits EventDone) renders the "Aborted" status line. + m.scanView.aborted = true case "q": if m.scanView.done { return m, tea.Quit diff --git a/main.go b/main.go index 1b52672..45f4a21 100644 --- a/main.go +++ b/main.go @@ -40,7 +40,7 @@ import ( const ( ProgramName = "subenum" - Version = "0.4.0" + Version = "0.5.1" DefaultDNSServer = "8.8.8.8:53" )