Skip to content

Commit ee04020

Browse files
TMHSDigitalclaude
andcommitted
feat(pkg): export Permutations from pkg/subenum (#197)
subenum.Permutations(seeds, words) wraps internal/permute.Generate, the generator -permute already uses, so library callers can build a second-pass candidate list without copying it. nil words selects the CLI's built-in list. Adds tests, ExamplePermutations, a section in docs/library.md and a CHANGELOG entry. The Diff half of the issue is not done: the diff logic lives in package main and its "removed"/"changed" semantics need decisions first. Refs #197 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LpkeXSPwrzd7ZCuX3no7gD
1 parent 0945011 commit ee04020

5 files changed

Lines changed: 90 additions & 0 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- `pkg/subenum` exports `Permutations(seeds, words []string) []string`, the candidate generator behind `-permute`: from names already found it returns the nearby variants (`api-dev`, `dev-api`, `dev.api`, `api2`), relative to the apex and ready to pass as a second scan's `Words`. `nil` words selects the CLI's built-in list. It sends no DNS queries (#197).
1112
- `-tui` pre-fills its form from the command line: `subenum -tui example.com -w big.txt -r resolvers.txt` opens with the domain, wordlist and resolver file filled in, over `SUBENUM_*`, config file and remembered values. Flags the form has no field for are named in a warning instead of being dropped silently; the form takes the first `-w`. The form gains **Resolver Pool** (`-r`) and **Rate/Resolver** (`-rate-per-resolver`) fields, remembered in `last.json`. After a scan, `r` restores the values just scanned rather than re-applying the launch flags over them (#195).
1213
- `-rate-per-resolver N` caps the DNS queries per second sent to each `-r` pool resolver, so a high global `-rate` no longer lands in uneven bursts on single members. Lookups go to resolvers with room first and wait only when every cap is spent; `-rate` still caps the total, and the stricter limit wins. The end-of-run resolver table and the report's `resolvers` entries gain a `throttled` count, and `pkg/subenum` gains `Config.RatePerResolver` (#196).
1314
- `-w` can be repeated to layer wordlists, for example a general list plus a target-specific one: `-w general.txt -w target.txt` reads both in order and scans an entry found in both only once (counted as a duplicate, as within one file). `-` stays valid as one of the entries. A `w` value from the config file or `SUBENUM_W` is replaced, not extended, by `-w` on the command line (#199).

‎docs/library.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ for ev := range events {
5959

6060
Read the channel until it closes, or cancel `ctx`. Breaking out of the loop with a deferred `cancel()` is safe: once `ctx` is cancelled the scan stops without waiting for a reader, and its goroutines exit within about a second whether or not the channel is read. A consumer that keeps reading after cancelling still gets `KindDone` with the counts so far. While `ctx` is live, a consumer that stops reading pauses the scan once the buffer fills.
6161

62+
## Permutations: second-pass candidates
63+
64+
`Permutations` makes the candidates the CLI's `-permute` tries: from names a scan already found, the variants most likely to sit next to them (`api` suggests `api-dev`, `dev-api`, `dev.api`, `api2` and so on). Seeds and results are relative to the apex, like `Words`, so the output feeds a second scan directly. Pass `nil` words for the CLI's built-in list of environment, version and role names. It sends no DNS queries:
65+
66+
```go
67+
seeds := []string{"api", "mail"} // api.example.com and mail.example.com were found
68+
more, _, err := subenum.Scan(ctx, subenum.Config{
69+
Domain: "example.com",
70+
Words: subenum.Permutations(seeds, nil),
71+
})
72+
```
73+
74+
The result is sorted and duplicate-free, and names that are not valid in DNS are left out.
75+
6276
## Configuration
6377

6478
| Field | Default | What it does |

‎pkg/subenum/example_test.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,20 @@ func ExampleConfig_recursive() {
150150
// dev.example.com
151151
// out of scope, never queried: 2
152152
}
153+
154+
// Permutations suggests the names most likely to sit next to ones a scan
155+
// already found. Its output is relative to the apex, so it can feed the Words
156+
// of a second scan. Pass nil words for the CLI's built-in list.
157+
func ExamplePermutations() {
158+
for _, name := range subenum.Permutations([]string{"api"}, []string{"dev"}) {
159+
fmt.Println(name)
160+
}
161+
162+
// Output:
163+
// api-dev
164+
// api1
165+
// api2
166+
// api3
167+
// dev-api
168+
// dev.api
169+
}

‎pkg/subenum/permutations_test.go‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package subenum_test
2+
3+
import (
4+
"slices"
5+
"testing"
6+
7+
"github.com/TMHSDigital/subenum/pkg/subenum"
8+
)
9+
10+
func TestPermutations(t *testing.T) {
11+
got := subenum.Permutations([]string{"API.us.", "", "api.us"}, []string{"dev", "api"})
12+
want := []string{"api-dev.us", "api1.us", "api2.us", "api3.us", "dev-api.us", "dev.api.us"}
13+
if !slices.Equal(got, want) {
14+
t.Fatalf("Permutations = %q, want %q", got, want)
15+
}
16+
}
17+
18+
func TestPermutationsNilWordsUsesBuiltinList(t *testing.T) {
19+
got := subenum.Permutations([]string{"api"}, nil)
20+
for _, name := range []string{"api-staging", "prod-api", "dev.api", "api-v2", "api2"} {
21+
if !slices.Contains(got, name) {
22+
t.Errorf("Permutations(nil words) is missing %q", name)
23+
}
24+
}
25+
if !slices.IsSorted(got) || len(slices.Compact(slices.Clone(got))) != len(got) {
26+
t.Errorf("Permutations(nil words) is not sorted and duplicate-free: %q", got)
27+
}
28+
if empty := subenum.Permutations([]string{"api"}, []string{}); len(empty) == 0 || slices.Contains(empty, "api-dev") {
29+
t.Errorf("Permutations(empty words) = %q, want only numbered variants", empty)
30+
}
31+
}
32+
33+
func TestPermutationsDropsInvalidLabels(t *testing.T) {
34+
// "-x" makes "-x.api", whose first label starts with a hyphen.
35+
if got := subenum.Permutations([]string{"api"}, []string{"-x"}); slices.Contains(got, "-x.api") {
36+
t.Errorf("Permutations kept the invalid name -x.api: %q", got)
37+
}
38+
if got := subenum.Permutations(nil, nil); len(got) != 0 {
39+
t.Errorf("Permutations(nil, nil) = %q, want none", got)
40+
}
41+
}

‎pkg/subenum/subenum.go‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727

2828
"github.com/TMHSDigital/subenum/data"
2929
"github.com/TMHSDigital/subenum/internal/dns"
30+
"github.com/TMHSDigital/subenum/internal/permute"
3031
"github.com/TMHSDigital/subenum/internal/scan"
3132
"github.com/TMHSDigital/subenum/internal/validate"
3233
"github.com/TMHSDigital/subenum/internal/wordlist"
@@ -343,6 +344,22 @@ var defaultWords = sync.OnceValue(func() []string {
343344
return words
344345
})
345346

347+
// Permutations returns second-pass candidates for names a scan already found,
348+
// the same ones the CLI's -permute tries: api suggests api-dev, dev-api,
349+
// dev.api, api2 and so on. Each seed is a name relative to the apex, such as
350+
// "api" for api.example.com or "api.us" for api.us.example.com; its leftmost
351+
// label is permuted and the rest kept. words are combined with each seed
352+
// label; nil selects the CLI's built-in list of environment, version and role
353+
// names. Candidates that are not valid DNS names are left out. The result is
354+
// relative to the apex, like Config.Words, sorted and duplicate-free, so it
355+
// can be passed as the Words of a follow-up scan. It sends no DNS queries.
356+
func Permutations(seeds, words []string) []string {
357+
if words == nil {
358+
words = permute.Words
359+
}
360+
return permute.Generate(seeds, words, nil)
361+
}
362+
346363
// doneGrace is how long Run's forwarder waits to hand over the final event
347364
// after ctx is cancelled, when the caller may have stopped reading.
348365
var doneGrace atomic.Int64 // a time.Duration; atomic so tests can change it while forwarders run

0 commit comments

Comments
 (0)