Skip to content

Commit 98ad84d

Browse files
feature(codes): move away from wordlist generation to simple PIN (4-digits) code generation.
1 parent 31d56c2 commit 98ad84d

File tree

4 files changed

+85
-129
lines changed

4 files changed

+85
-129
lines changed

.editorconfig

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
insert_final_newline = true
7+
indent_style = tab
8+
indent_size = 4
9+
10+
[*.md]
11+
trim_trailing_whitespace = false

internal/rendezvous/codes.go

Lines changed: 36 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,19 @@ type CodeEntry struct {
2222
}
2323

2424
type Store struct {
25-
mu sync.RWMutex
26-
codes map[string]*CodeEntry // code -> entry
27-
appToCode map[string]string // appID -> code
28-
pregen chan string // optional pregen pool
29-
ttl time.Duration
30-
words []string
31-
perm []int // permutation of [0..len(words)-1], set once
32-
singleIdx int // rotating cursor for single-word picks
33-
pairA, pairB int // rotating cursors for two-word pairs
25+
mu sync.RWMutex
26+
codes map[string]*CodeEntry // code -> entry
27+
appToCode map[string]string // appID -> code
28+
pregen chan string // optional pregen pool
29+
ttl time.Duration
30+
31+
// The following fields are kept for backward-compat constructors but unused
32+
// now that codes are PINs. Safe to remove later if desired.
33+
words []string
34+
perm []int
35+
singleIdx int
36+
pairA int
37+
pairB int
3438
}
3539

3640
func NewStore(words []string, ttl time.Duration, pregenCap int) *Store {
@@ -43,19 +47,6 @@ func NewStore(words []string, ttl time.Duration, pregenCap int) *Store {
4347
ttl: ttl,
4448
words: words,
4549
}
46-
if n := len(words); n > 0 {
47-
s.perm = make([]int, n)
48-
for i := 0; i < n; i++ {
49-
s.perm[i] = i
50-
}
51-
// Fisher–Yates with crypto/rand
52-
for i := n - 1; i > 0; i-- {
53-
j, err := randInt(i + 1)
54-
if err == nil && j != i {
55-
s.perm[i], s.perm[j] = s.perm[j], s.perm[i]
56-
}
57-
}
58-
}
5950
if pregenCap > 0 {
6051
s.pregen = make(chan string, pregenCap)
6152
go s.pregenerator()
@@ -139,6 +130,7 @@ func (s *Store) pregenerator() {
139130
}
140131
code, err := s.newUniqueCode()
141132
if err != nil {
133+
// If space is temporarily exhausted, back off a bit.
142134
time.Sleep(20 * time.Millisecond)
143135
continue
144136
}
@@ -166,6 +158,7 @@ func (s *Store) takeCodeFromPool() (string, bool) {
166158
}
167159

168160
func (s *Store) newUniqueCode() (string, error) {
161+
// Fast attempts first.
169162
for tries := 0; tries < 8; tries++ {
170163
c, err := s.generateCode()
171164
if err != nil {
@@ -178,6 +171,7 @@ func (s *Store) newUniqueCode() (string, error) {
178171
return c, nil
179172
}
180173
}
174+
// Keep trying until we find a free PIN (or space is exhausted).
181175
for {
182176
c, err := s.generateCode()
183177
if err != nil {
@@ -192,103 +186,39 @@ func (s *Store) newUniqueCode() (string, error) {
192186
}
193187
}
194188

189+
// generateCode now produces a unique 4-digit PIN "0000".."9999".
195190
func (s *Store) generateCode() (string, error) {
196-
// We need to see what's currently allocated, and advance our fair cursors.
191+
const maxPins = 10000
192+
197193
s.mu.Lock()
198194
defer s.mu.Unlock()
199195

200-
// 1) Try single-word pool.
201-
if code, ok := s.pickSingleLocked(); ok {
202-
return code, nil
196+
// If the entire PIN space is allocated, bail out.
197+
if len(s.codes) >= maxPins {
198+
return "", errors.New("PIN space exhausted")
203199
}
204-
// 2) All single words are currently allocated -> try two-word pool.
205-
if code, ok := s.pickPairLocked(); ok {
206-
return code, nil
207-
}
208-
// 3) Extremely unlikely to get here. Fall back to legacy generator (4 words + 4 digits).
209-
return s.generateLegacyUnlocked()
210-
}
211200

212-
// pickSingleLocked returns an unused single-word code if available.
213-
// Requires s.mu to be held (write lock) because it advances s.singleIdx.
214-
func (s *Store) pickSingleLocked() (string, bool) {
215-
n := len(s.words)
216-
if n == 0 {
217-
return "", false
218-
}
219-
if len(s.perm) != n {
220-
// Safety: build a basic permutation if not set (shouldn't happen).
221-
s.perm = make([]int, n)
222-
for i := range s.perm {
223-
s.perm[i] = i
201+
// Try a handful of random picks first.
202+
for tries := 0; tries < 16; tries++ {
203+
n, err := randInt(maxPins)
204+
if err != nil {
205+
return "", err
224206
}
225-
}
226-
for tried := 0; tried < n; tried++ {
227-
idx := s.perm[(s.singleIdx+tried)%n]
228-
w := s.words[idx]
229-
if _, inUse := s.codes[w]; !inUse {
230-
// Reserve this candidate by advancing the cursor; actual insertion happens in CreateCode.
231-
s.singleIdx = (s.singleIdx + tried + 1) % n
232-
return w, true
207+
pin := fmt.Sprintf("%04d", n)
208+
if _, inUse := s.codes[pin]; !inUse {
209+
return pin, nil
233210
}
234211
}
235-
return "", false
236-
}
237212

238-
// pickPairLocked returns an unused two-word code "w1-w2" if available.
239-
// Requires s.mu to be held (write lock) because it advances s.pairA/B.
240-
func (s *Store) pickPairLocked() (string, bool) {
241-
n := len(s.words)
242-
if n == 0 {
243-
return "", false
244-
}
245-
if len(s.perm) != n {
246-
s.perm = make([]int, n)
247-
for i := range s.perm {
248-
s.perm[i] = i
213+
// Fallback: linear scan to find any free PIN.
214+
for i := 0; i < maxPins; i++ {
215+
pin := fmt.Sprintf("%04d", i)
216+
if _, inUse := s.codes[pin]; !inUse {
217+
return pin, nil
249218
}
250219
}
251-
// Scan n*n pairs in a rotating manner.
252-
for scanned := 0; scanned < n*n; scanned++ {
253-
a := s.words[s.perm[s.pairA]]
254-
b := s.words[s.perm[s.pairB]]
255-
code := a + "-" + b
256-
257-
// Move pairB then roll into pairA
258-
s.pairB++
259-
if s.pairB >= n {
260-
s.pairB = 0
261-
s.pairA = (s.pairA + 1) % n
262-
}
263-
264-
if _, inUse := s.codes[code]; !inUse {
265-
return code, true
266-
}
267-
}
268-
return "", false
269-
}
270220

271-
// generateLegacyUnlocked reproduces the old style code (4 words + 4 digits).
272-
// Requires s.mu to be held (write lock) only because we share randInt; it doesn't mutate s.
273-
func (s *Store) generateLegacyUnlocked() (string, error) {
274-
const wordsPerCode = 4
275-
if len(s.words) < 2 {
276-
return "", errors.New("wordlist too small")
277-
}
278-
parts := make([]string, 0, wordsPerCode+1)
279-
for i := 0; i < wordsPerCode; i++ {
280-
idx, err := randInt(len(s.words))
281-
if err != nil {
282-
return "", err
283-
}
284-
parts = append(parts, s.words[idx])
285-
}
286-
d, err := randInt(10000) // 0000..9999
287-
if err != nil {
288-
return "", err
289-
}
290-
parts = append(parts, fmt.Sprintf("%04d", d))
291-
return strings.ToLower(strings.Join(parts, "-")), nil
221+
return "", errors.New("PIN space exhausted")
292222
}
293223

294224
func randInt(n int) (int, error) {

internal/rendezvous/codes_smart_test.go

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,59 @@ package rendezvous
22

33
import (
44
"context"
5+
"regexp"
56
"testing"
67
"time"
78
)
89

9-
func Test_SingleThenPairs(t *testing.T) {
10-
words := []string{"alpha", "bravo", "charlie"}
11-
s := NewStore(words, 5*time.Minute, 0)
10+
func Test_Generates4DigitPins_UniqueAndRedeemable(t *testing.T) {
11+
s := NewStore(nil, 2*time.Minute, 0)
1212

1313
seen := map[string]bool{}
14-
var singles int
14+
re := regexp.MustCompile(`^\d{4}$`)
1515

16-
// Allocate more than len(words) to force pairs.
17-
for i := 0; i < len(words)+2; i++ {
18-
code, appID, _, err := s.CreateCode(context.Background())
16+
// Generate a healthy number of codes to exercise collisions/uniqueness.
17+
for i := 0; i < 500; i++ {
18+
code, appID, exp, err := s.CreateCode(context.Background())
1919
if err != nil {
2020
t.Fatalf("create: %v", err)
2121
}
22+
if !re.MatchString(code) {
23+
t.Fatalf("code not 4 digits: %q", code)
24+
}
2225
if seen[code] {
23-
t.Fatalf("duplicate code: %s", code)
26+
t.Fatalf("duplicate code generated: %s", code)
2427
}
2528
seen[code] = true
29+
2630
if appID == "" {
2731
t.Fatalf("missing appID")
2832
}
29-
if countHyphens(code) == 0 {
30-
singles++
33+
if time.Until(exp) <= 0 {
34+
t.Fatalf("bad expiry")
3135
}
32-
}
3336

34-
if singles != len(words) {
35-
t.Fatalf("expected %d singles first, got %d", len(words), singles)
37+
// Redeem should return the same appID/exp.
38+
gotApp, gotExp, ok := s.RedeemCode(context.Background(), code)
39+
if !ok {
40+
t.Fatalf("redeem failed for %s", code)
41+
}
42+
if gotApp != appID || !gotExp.Equal(exp) {
43+
t.Fatalf("redeem mismatch: want (%s,%v) got (%s,%v)", appID, exp, gotApp, gotExp)
44+
}
3645
}
3746
}
3847

39-
func countHyphens(s string) int {
40-
n := 0
41-
for _, r := range s {
42-
if r == '-' {
43-
n++
48+
func Test_PinSpaceExhaustion(t *testing.T) {
49+
// Very short TTL means janitor might free, but we won't sleep, so map only grows.
50+
s := NewStore(nil, time.Hour, 0)
51+
for i := 0; i < 10000; i++ {
52+
if _, _, _, err := s.CreateCode(context.Background()); err != nil {
53+
t.Fatalf("unexpected error before exhaustion: %v", err)
4454
}
4555
}
46-
return n
56+
// Next allocation should fail due to 10k space exhausted.
57+
if _, _, _, err := s.CreateCode(context.Background()); err == nil {
58+
t.Fatalf("expected exhaustion error, got nil")
59+
}
4760
}

internal/server/server.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"encoding/json"
66
"log/slog"
77
"net/http"
8-
"net/http/pprof"
98
"os"
109
"runtime"
1110
"strings"
@@ -107,7 +106,7 @@ func Build(p BuildParams, opt ...any) (*App, error) {
107106
w.WriteHeader(http.StatusNoContent)
108107
})
109108

110-
// Optional pprof (NT_PPROF=true/1/on)
109+
/* Optional pprof (NT_PPROF=true/1/on)
111110
if v := strings.ToLower(strings.TrimSpace(os.Getenv("NT_PPROF"))); v == "1" || v == "true" || v == "on" {
112111
mux.HandleFunc("/debug/pprof/", pprof.Index)
113112
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
@@ -116,6 +115,7 @@ func Build(p BuildParams, opt ...any) (*App, error) {
116115
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
117116
log.Info("pprof enabled on /debug/pprof/*")
118117
}
118+
*/
119119

120120
// WS
121121
wsx.SetDevMode(p.DevMode)
@@ -179,8 +179,10 @@ func Build(p BuildParams, opt ...any) (*App, error) {
179179
)
180180
}
181181

182-
mux.Handle("/objects", objHandler)
183-
mux.Handle("/objects/", objHandler)
182+
/*
183+
mux.Handle("/objects", objHandler)
184+
mux.Handle("/objects/", objHandler)
185+
*/
184186

185187
// health & metrics
186188
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })

0 commit comments

Comments
 (0)