Skip to content

Commit 5dd158d

Browse files
fix: finalize structured output only on success; docs facelift + em-dash normalize
Code: - main.go: move out.Finish() off defer to just before the success "return 0". Previously the deferred finalizer ran on every exit path, so an early error (e.g. wildcard detection without -force) emitted an empty JSON array in -format json. Text mode is unchanged (Finish is a no-op for text). The file flush/close defer still runs after Finish, so buffered JSON/CSV is persisted before the file closes. - internal/output: add TestWriterJSONFinishGatesOutput documenting that structured output is emitted only by Finish (no Finish = no spurious array). Docs: - docs/index.md: refreshed to the 0.6.0 feature set with cards for Output Formats (-format), Rate Limiting (-rate), Record Types (-type), and Recursive Enumeration (-recursive/-depth), matching the README Feature Matrix. - Normalized em dashes to hyphens across docs/ (index, ARCHITECTURE, DEVELOPER_GUIDE, _config.yml, default.html). Arrows and code identifiers left intact; changelog history untouched. Logged under CHANGELOG [Unreleased]. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent f7a2d52 commit 5dd158d

8 files changed

Lines changed: 117 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
- Structured output (`-format json` and `-format csv`) is now finalized only on the successful scan path. The finalizer previously ran via `defer` on every exit, so an early error (such as wildcard detection without `-force`) emitted an empty JSON array. Text output behavior is unchanged.
12+
13+
### Docs
14+
- GitHub Pages landing page (`docs/index.md`) refreshed to the 0.6.0 feature set, adding cards for Output Formats (`-format`), Rate Limiting (`-rate`), Record Types (`-type`), and Recursive Enumeration (`-recursive`/`-depth`).
15+
- Normalized em dashes to hyphens across `docs/` for consistency with the no-em-dash convention.
16+
817
## [0.6.0] - 2026-06-03
918

1019
### Added

docs/ARCHITECTURE.md

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,16 @@ This architecture is designed to be efficient by performing multiple DNS lookups
2323
### Package Structure
2424

2525
```
26-
main.go CLI entry point (flag parsing, wiring, -tui dispatch)
27-
internal/scan/runner.go Scan engine: Config, Event types, Run(ctx, cfg, events)
28-
internal/dns/resolver.go ResolveDomain, ResolveDomainWithRetry, CheckWildcard
29-
internal/dns/simulate.go SimulateResolution
30-
internal/output/writer.go Thread-safe Writer (results→stdout, diagnostics→stderr)
31-
internal/wordlist/reader.go LoadWordlist (dedup + sanitize)
32-
internal/tui/model.go Root Bubble Tea model (form → scan state machine)
33-
internal/tui/form.go Config form screen (textinput fields + toggles)
34-
internal/tui/scan_view.go Live results screen (viewport + progress bar)
35-
internal/tui/config.go Session persistence (load/save ~/.config/subenum/last.json)
26+
main.go - CLI entry point (flag parsing, wiring, -tui dispatch)
27+
internal/scan/runner.go - Scan engine: Config, Event types, Run(ctx, cfg, events)
28+
internal/dns/resolver.go - ResolveDomain, ResolveDomainWithRetry, CheckWildcard
29+
internal/dns/simulate.go - SimulateResolution
30+
internal/output/writer.go - Thread-safe Writer (results→stdout, diagnostics→stderr)
31+
internal/wordlist/reader.go - LoadWordlist (dedup + sanitize)
32+
internal/tui/model.go - Root Bubble Tea model (form → scan state machine)
33+
internal/tui/form.go - Config form screen (textinput fields + toggles)
34+
internal/tui/scan_view.go - Live results screen (viewport + progress bar)
35+
internal/tui/config.go - Session persistence (load/save ~/.config/subenum/last.json)
3636
```
3737

3838
## 2. Key Components / Modules
@@ -71,7 +71,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
7171
* Function: `dns.Resolve(ctx, domain, timeout, dnsServer) ([]Record, time.Duration, error)` and `dns.ResolveTypes(..., types)` - perform the lookups and return typed `Record{Type, Value}` results. `ResolveTypes` issues per-type lookups (`LookupIP` ip4/ip6 for A/AAAA, `LookupCNAME` for CNAME) and filters to the requested types (default A,AAAA via `-type`).
7272
* Function: `dns.ResolveDomain(ctx, domain, timeout, dnsServer, verbose) bool` - convenience wrapper returning a boolean, used by wildcard detection.
7373
* Function: `dns.ResolveDomainWithRetry(ctx, domain, timeout, dnsServer, verbose, maxAttempts, types) ([]Record, bool)` - wraps the lookup with configurable retry logic and linear backoff between attempts, returning the resolved records.
74-
* Function: `dns.CheckWildcard(ctx, domain, timeout, dnsServer) (bool, error)` resolves two random subdomains to detect wildcard DNS records.
74+
* Function: `dns.CheckWildcard(ctx, domain, timeout, dnsServer) (bool, error)` - resolves two random subdomains to detect wildcard DNS records.
7575
* `net.Resolver{}`: A custom DNS resolver is configured.
7676
* `PreferGo: true`: Instructs the resolver to use the pure Go DNS client.
7777
* `Dial func(ctx context.Context, network, address string) (net.Conn, error)`: A custom dial function is provided to control the connection to the DNS server, using the user-specified `dnsServer` address.
@@ -86,7 +86,7 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
8686
* **Purpose**: To efficiently perform DNS lookups for a large number of potential subdomains, `subenum` employs a worker pool pattern. This allows multiple DNS queries to be in flight concurrently, significantly speeding up the enumeration process compared to sequential lookups.
8787
* **Implementation**: The worker pool logic lives in `internal/scan/runner.go` as `scan.Run(ctx, cfg, events)`. Both the CLI (`run()` in `main.go`) and the TUI (`internal/tui`) call this function.
8888
* **`scan.Config`**: A struct carrying all scan parameters (domain, entries slice, concurrency, timeout, DNS server, simulate flag, etc.).
89-
* **`scan.Event` / `scan.EventKind`**: Typed events emitted on a `chan<- scan.Event` `EventResult`, `EventProgress`, `EventWildcard`, `EventError`, `EventDone`.
89+
* **`scan.Event` / `scan.EventKind`**: Typed events emitted on a `chan<- scan.Event` - `EventResult`, `EventProgress`, `EventWildcard`, `EventError`, `EventDone`.
9090
* **Dispatcher and work queue**: A dispatcher goroutine owns an internal `jobs` channel, the queue of pending `job{domain, depth}` items, a visited set, and a pending-work counter. It seeds the queue from the wordlist slice and feeds workers. Workers submit newly discovered children back to the dispatcher over an `enqueue` channel and signal each finished job over a `completed` channel. The dispatcher closes `jobs` only when the pending counter reaches zero (or the context is cancelled). This lifecycle lets resolved subdomains enqueue children safely (recursive mode) without risking a send on a closed channel.
9191
* **`var wg sync.WaitGroup`**: A `sync.WaitGroup` waits for all worker goroutines to finish.
9292
* **Worker Goroutines Loop**: `cfg.Concurrency` goroutines are launched. Each reads a job from `jobs`, constructs nothing further (the job already holds the full domain), and calls `dns.ResolveDomainWithRetry()` (or `dns.SimulateResolve()` in simulate mode).
@@ -102,11 +102,11 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
102102
* **Implementation**:
103103
* `output.Writer` struct with mutex-protected methods:
104104
* `Result(domain, records)` - in `text` format prints `Found: <domain>` to stdout (and the output file if configured); in `json` format buffers `{"subdomain", "records"}` objects and writes a single array at completion; in `csv` format streams `subdomain,type,value` rows with a header. The format is selected with `-format text|json|csv` (default `text`, which is byte-for-byte identical to prior behavior). The JSON array is buffered because it is a single document and does not stream; JSONL would be the streaming-friendly alternative if needed. Output formats are CLI-only for now (TUI-pending).
105-
* `Progress(pct, processed, total, found)` writes a carriage-return progress line to stderr.
106-
* `Info(format, args...)` writes an informational line to stderr.
107-
* `Error(format, args...)` writes an error line to stderr.
105+
* `Progress(pct, processed, total, found)` - writes a carriage-return progress line to stderr.
106+
* `Info(format, args...)` - writes an informational line to stderr.
107+
* `Error(format, args...)` - writes an error line to stderr.
108108
* **Verbose Output** (when `-v` flag is enabled):
109-
* Configuration summary, per-query DNS resolution info, and final scan statistics all via `Info` to stderr.
109+
* Configuration summary, per-query DNS resolution info, and final scan statistics - all via `Info` to stderr.
110110
* **Progress Reporting** (when `-progress` flag is enabled):
111111
* A dedicated goroutine using a 1-second ticker calls `Progress` on stderr.
112112
* **Interactions**: All components route output through the `Writer`. Since results are the only thing on stdout, piping (`| cut -d' ' -f2`) works without `-progress=false`.
@@ -130,9 +130,9 @@ internal/tui/config.go — Session persistence (load/save ~/.config/sube
130130
* **Purpose**: Remember the last-used TUI form values across sessions so users don't have to re-type domain, wordlist path, and scan parameters every time.
131131
* **Implementation**:
132132
* `savedConfig` struct mirrors `formValues` with JSON tags.
133-
* `configPath()` returns `os.UserConfigDir()/subenum/last.json` (e.g. `~/.config/subenum/last.json` on Linux/macOS, `%AppData%\subenum\last.json` on Windows).
134-
* `saveConfig(fv formValues) error` marshals `formValues` to JSON and writes it atomically with `os.WriteFile`. Called in `beginScan()` immediately before launching the scan goroutine. Errors are silently discarded so a write failure never blocks the scan.
135-
* `loadSavedConfig() (savedConfig, bool)` reads and unmarshals the file. Returns `false` if the file doesn't exist or is unreadable, causing `newFormModel` to fall back to hardcoded defaults.
133+
* `configPath()` - returns `os.UserConfigDir()/subenum/last.json` (e.g. `~/.config/subenum/last.json` on Linux/macOS, `%AppData%\subenum\last.json` on Windows).
134+
* `saveConfig(fv formValues) error` - marshals `formValues` to JSON and writes it atomically with `os.WriteFile`. Called in `beginScan()` immediately before launching the scan goroutine. Errors are silently discarded so a write failure never blocks the scan.
135+
* `loadSavedConfig() (savedConfig, bool)` - reads and unmarshals the file. Returns `false` if the file doesn't exist or is unreadable, causing `newFormModel` to fall back to hardcoded defaults.
136136
* **Interactions**: `tui.New()` calls `loadSavedConfig()` on startup and passes the result to `newFormModel`. The `r` keybind (new scan) also calls `loadSavedConfig()` so the form is pre-filled with the values from the scan that just completed.
137137

138138
## 3. Data Flow

docs/DEVELOPER_GUIDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,8 @@ Please follow these style guidelines when contributing:
242242

243243
The CLI path (`run()`) has zero external dependencies. The TUI path (`-tui` flag) adds:
244244

245-
- [`github.com/charmbracelet/bubbletea`](https://github.com/charmbracelet/bubbletea) Elm-architecture terminal UI framework
246-
- [`github.com/charmbracelet/bubbles`](https://github.com/charmbracelet/bubbles) reusable TUI components (textinput, viewport, progress bar)
245+
- [`github.com/charmbracelet/bubbletea`](https://github.com/charmbracelet/bubbletea) - Elm-architecture terminal UI framework
246+
- [`github.com/charmbracelet/bubbles`](https://github.com/charmbracelet/bubbles) - reusable TUI components (textinput, viewport, progress bar)
247247

248248
If you need to add a further dependency:
249249

@@ -258,7 +258,7 @@ If you need to add a further dependency:
258258
259259
Areas for potential enhancement include:
260260
261-
* **Terminal UI**: An interactive TUI (`-tui` flag) built with Bubble Tea. Provides a form-based config screen and a live-scrolling results view no arguments required to launch. Last-used values persist to `~/.config/subenum/last.json` across sessions.
261+
* **Terminal UI**: An interactive TUI (`-tui` flag) built with Bubble Tea. Provides a form-based config screen and a live-scrolling results view - no arguments required to launch. Last-used values persist to `~/.config/subenum/last.json` across sessions.
262262
* **Output Formats**: Supporting different output formats (JSON, CSV) in addition to the current plain text output file (`-o`).
263263
* **Result Filtering**: Allowing users to filter results based on DNS record types.
264264
* **Recursive Enumeration**: Adding support for recursive subdomain enumeration (e.g., finding subdomains of discovered subdomains).

docs/_config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
theme: jekyll-theme-cayman
22
title: subenum
3-
description: Fast, concurrent subdomain enumeration via DNS brute-forcing written in pure Go.
3+
description: Fast, concurrent subdomain enumeration via DNS brute-forcing - written in pure Go.
44
show_downloads: false
55
url: "https://tmhsdigital.github.io"
66
baseurl: "/subenum"

docs/_layouts/default.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8">
55
<meta http-equiv="X-UA-Compatible" content="IE=edge">
66
<meta name="viewport" content="width=device-width, initial-scale=1">
7-
<title>{% if page.title == "Home" %}{{ site.title }} {{ site.description }}{% else %}{{ page.title }} {{ site.title }}{% endif %}</title>
7+
<title>{% if page.title == "Home" %}{{ site.title }} - {{ site.description }}{% else %}{{ page.title }} - {{ site.title }}{% endif %}</title>
88
<link rel="stylesheet" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
99
<style>
1010
/* ── Nav bar ── */
@@ -58,7 +58,7 @@
5858
padding: 5rem 2rem 4rem;
5959
background: linear-gradient(160deg, #0d1117 0%, #161b22 60%, #1a2332 100%);
6060
}
61-
/* Hide Cayman's default title/tagline we render our own */
61+
/* Hide Cayman's default title/tagline - we render our own */
6262
.page-header .project-name,
6363
.page-header .project-tagline { display: none; }
6464

@@ -137,7 +137,7 @@
137137
margin: 1rem 0;
138138
}
139139
.main-content blockquote p { margin: 0; color: #57606a; }
140-
/* Links restore blue on top of the * reset */
140+
/* Links - restore blue on top of the * reset */
141141
.main-content a { color: #0969da !important; }
142142
.main-content a:hover { text-decoration: underline; }
143143
/* Inline code */
@@ -148,7 +148,7 @@
148148
padding: 1px 5px;
149149
font-size: 0.88em;
150150
}
151-
/* Block code distinct background from page */
151+
/* Block code - distinct background from page */
152152
.main-content pre {
153153
background: #f0f2f5;
154154
border: 1px solid #d0d7de;

docs/index.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ title: Home
99

1010
## What it does
1111

12-
subenum brute-forces subdomains by resolving a wordlist against a target domain using a concurrent worker pool. Results stream to stdout pipe-clean, no noise. Everything else (progress, diagnostics, errors) goes to stderr.
12+
subenum brute-forces subdomains by resolving a wordlist against a target domain using a concurrent worker pool. Results stream to stdout - pipe-clean, no noise. Everything else (progress, diagnostics, errors) goes to stderr.
1313

1414
<div class="screenshot-wrap">
1515
<figure>
16-
<img src="assets/tui-form.png" alt="subenum TUI Configure Scan">
17-
<figcaption>Interactive TUI launch with <code>./subenum -tui</code> or <code>make tui</code></figcaption>
16+
<img src="assets/tui-form.png" alt="subenum TUI - Configure Scan">
17+
<figcaption>Interactive TUI - launch with <code>./subenum -tui</code> or <code>make tui</code></figcaption>
1818
</figure>
1919
</div>
2020

@@ -37,7 +37,23 @@ subenum brute-forces subdomains by resolving a wordlist against a target domain
3737
</div>
3838
<div class="feature-card">
3939
<strong>Simulation Mode</strong>
40-
<span>Generate synthetic DNS results at a configurable hit rate — zero network I/O. Safe for demos and testing.</span>
40+
<span>Generate synthetic DNS results at a configurable hit rate - zero network I/O. Safe for demos and testing.</span>
41+
</div>
42+
<div class="feature-card">
43+
<strong>Output Formats</strong>
44+
<span>Emit results as <code>text</code>, <code>json</code> (array of subdomain plus typed records), or <code>csv</code> via <code>-format</code>.</span>
45+
</div>
46+
<div class="feature-card">
47+
<strong>Rate Limiting</strong>
48+
<span>Cap total DNS queries per second across the worker pool with <code>-rate</code> (context-aware, stays responsive to Ctrl+C).</span>
49+
</div>
50+
<div class="feature-card">
51+
<strong>Record Types</strong>
52+
<span>Look up and filter by <code>A</code>, <code>AAAA</code>, or <code>CNAME</code> records with <code>-type</code>.</span>
53+
</div>
54+
<div class="feature-card">
55+
<strong>Recursive Enumeration</strong>
56+
<span>Enumerate subdomains of discovered subdomains with <code>-recursive</code> and a <code>-depth</code> cap, with loop and duplicate protection.</span>
4157
</div>
4258
<div class="feature-card">
4359
<strong>Pipe-Friendly Output</strong>

internal/output/writer_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,62 @@ func TestWriterResultJSONFile(t *testing.T) {
100100
}
101101
}
102102

103+
// TestWriterJSONFinishGatesOutput locks in the contract main.run relies on:
104+
// structured output is emitted only by Finish, so skipping Finish on an error
105+
// path produces no spurious empty JSON array.
106+
func TestWriterJSONFinishGatesOutput(t *testing.T) {
107+
// Error path: results buffered (or none) but Finish never called.
108+
noFinish, err := os.CreateTemp("", "output-json-nofinish-*.json")
109+
if err != nil {
110+
t.Fatal(err)
111+
}
112+
defer func() { _ = os.Remove(noFinish.Name()) }()
113+
114+
bw := bufio.NewWriter(noFinish)
115+
_ = New(bw, false, FormatJSON)
116+
if err := bw.Flush(); err != nil {
117+
t.Fatal(err)
118+
}
119+
if err := noFinish.Close(); err != nil {
120+
t.Fatal(err)
121+
}
122+
content, err := os.ReadFile(noFinish.Name())
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
if len(content) != 0 {
127+
t.Errorf("expected no structured output without Finish, got:\n%s", content)
128+
}
129+
130+
// Success path: Finish with zero results emits an empty JSON array.
131+
withFinish, err := os.CreateTemp("", "output-json-finish-*.json")
132+
if err != nil {
133+
t.Fatal(err)
134+
}
135+
defer func() { _ = os.Remove(withFinish.Name()) }()
136+
137+
bw2 := bufio.NewWriter(withFinish)
138+
w := New(bw2, false, FormatJSON)
139+
w.Finish()
140+
if err := bw2.Flush(); err != nil {
141+
t.Fatal(err)
142+
}
143+
if err := withFinish.Close(); err != nil {
144+
t.Fatal(err)
145+
}
146+
content, err = os.ReadFile(withFinish.Name())
147+
if err != nil {
148+
t.Fatal(err)
149+
}
150+
var results []Result
151+
if err := json.Unmarshal(content, &results); err != nil {
152+
t.Fatalf("Finish output is not a valid JSON array: %v\nGot:\n%s", err, content)
153+
}
154+
if len(results) != 0 {
155+
t.Errorf("expected empty array from Finish with no results, got %d", len(results))
156+
}
157+
}
158+
103159
func TestWriterResultCSVFile(t *testing.T) {
104160
tmp, err := os.CreateTemp("", "output-csv-*.csv")
105161
if err != nil {

main.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,6 @@ func run() int {
285285
}
286286
}()
287287
}
288-
// Finish runs before the file flush/close defer above (LIFO), so buffered
289-
// JSON and streamed CSV are written before the file is closed.
290-
defer out.Finish()
291-
292288
if f.verbose {
293289
logVerboseStart(f, domain, maxAttempts, out)
294290
}
@@ -370,6 +366,11 @@ func run() int {
370366
}
371367
}
372368
}
369+
// Finalize structured output only on the success path, so an early error
370+
// (such as wildcard detection without -force) does not emit an empty JSON
371+
// array or a bare CSV header. The deferred file flush/close registered
372+
// above runs after this, persisting buffered output before the file closes.
373+
out.Finish()
373374
return 0
374375
}
375376

0 commit comments

Comments
 (0)