Skip to content

Commit 3bb8339

Browse files
MagicalTuxclaude
andcommitted
add go.mod, tests, CI workflow, and fix issues
- Add go.mod for Go 1.24 - Add comprehensive tests for all functions - Add GitHub Actions workflow for Go 1.24/1.25 - Add bounds checking to readint31n - Deprecate Simple and SimpleFastReader with security warnings - Update to math/rand/v2 API - Improve documentation for all exports - Make Makefile portable 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 041c7a9 commit 3bb8339

9 files changed

Lines changed: 526 additions & 26 deletions

File tree

.github/workflows/test.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [master]
6+
pull_request:
7+
branches: [master]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
go-version: ['1.24', '1.25']
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Go ${{ matrix.go-version }}
20+
uses: actions/setup-go@v5
21+
with:
22+
go-version: ${{ matrix.go-version }}
23+
24+
- name: Build
25+
run: go build -v ./...
26+
27+
- name: Test
28+
run: go test -v -race ./...
29+
30+
- name: Test Coverage
31+
run: go test -coverprofile=coverage.out ./...
32+
33+
- name: Upload coverage
34+
uses: codecov/codecov-action@v4
35+
with:
36+
files: ./coverage.out
37+
fail_ci_if_error: false

Makefile

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,18 @@
11
#!/bin/make
22

3-
GOROOT:=$(shell PATH="/pkg/main/dev-lang.go/bin:$$PATH" go env GOROOT)
4-
GO_TAG:=$(shell /bin/sh -c 'eval `$(GOROOT)/bin/go tool dist env`; echo "$${GOOS}_$${GOARCH}"')
5-
GIT_TAG:=$(shell git rev-parse --short HEAD)
6-
GOPATH:=$(shell $(GOROOT)/bin/go env GOPATH)
3+
.PHONY: all test fmt
74

8-
all: build
5+
all: fmt build
96

10-
.PHONY: build test deps
7+
fmt:
8+
goimports -w -l . || gofmt -w -l .
119

1210
build:
13-
$(GOPATH)/bin/goimports -w -l .
14-
$(GOROOT)/bin/go build ./...
11+
go build -v ./...
1512

1613
test:
17-
$(GOROOT)/bin/go test -v ./...
14+
go test -v ./...
1815

19-
deps:
20-
$(GOROOT)/bin/go get -v -t .
16+
coverage:
17+
go test -coverprofile=coverage.out ./...
18+
go tool cover -html=coverage.out -o coverage.html

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/KarpelesLab/rndstr
2+
3+
go 1.24

rand.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ package rndstr
22

33
import (
44
"encoding/binary"
5+
"errors"
56
"io"
67
)
78

9+
// ErrInvalidRange is returned when an invalid range parameter is provided.
10+
var ErrInvalidRange = errors.New("rndstr: range must be greater than zero")
11+
812
func readuint32(r io.Reader) (uint32, error) {
913
b := make([]byte, 4)
1014
_, err := io.ReadFull(r, b)
@@ -54,6 +58,9 @@ func readuint32(r io.Reader) (uint32, error) {
5458
// https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction
5559
// https://lemire.me/blog/2016/06/30/fast-random-shuffling
5660
func readint31n(r io.Reader, n int32) (int32, error) {
61+
if n <= 0 {
62+
return 0, ErrInvalidRange
63+
}
5764
v, err := readuint32(r)
5865
if err != nil {
5966
return 0, err

rand_test.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package rndstr
2+
3+
import (
4+
"bytes"
5+
"crypto/rand"
6+
"encoding/binary"
7+
"io"
8+
"testing"
9+
)
10+
11+
func TestReaduint32(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
input []byte
15+
want uint32
16+
wantErr bool
17+
}{
18+
{"zero", []byte{0, 0, 0, 0}, 0, false},
19+
{"one", []byte{1, 0, 0, 0}, 1, false},
20+
{"max", []byte{255, 255, 255, 255}, 0xFFFFFFFF, false},
21+
{"mixed", []byte{0x78, 0x56, 0x34, 0x12}, 0x12345678, false},
22+
{"short", []byte{1, 2, 3}, 0, true},
23+
{"empty", []byte{}, 0, true},
24+
}
25+
26+
for _, tt := range tests {
27+
t.Run(tt.name, func(t *testing.T) {
28+
r := bytes.NewReader(tt.input)
29+
got, err := readuint32(r)
30+
if (err != nil) != tt.wantErr {
31+
t.Errorf("readuint32() error = %v, wantErr %v", err, tt.wantErr)
32+
return
33+
}
34+
if !tt.wantErr && got != tt.want {
35+
t.Errorf("readuint32() = %v, want %v", got, tt.want)
36+
}
37+
})
38+
}
39+
}
40+
41+
func TestReadint31n(t *testing.T) {
42+
tests := []struct {
43+
name string
44+
n int32
45+
wantErr error
46+
}{
47+
{"positive", 100, nil},
48+
{"one", 1, nil},
49+
{"large", 1000000, nil},
50+
{"zero", 0, ErrInvalidRange},
51+
{"negative", -1, ErrInvalidRange},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
got, err := readint31n(rand.Reader, tt.n)
57+
if err != tt.wantErr {
58+
t.Errorf("readint31n(rand.Reader, %d) error = %v, want %v", tt.n, err, tt.wantErr)
59+
return
60+
}
61+
if tt.wantErr == nil {
62+
if got < 0 || got >= tt.n {
63+
t.Errorf("readint31n(rand.Reader, %d) = %d, want [0, %d)", tt.n, got, tt.n)
64+
}
65+
}
66+
})
67+
}
68+
}
69+
70+
func TestReadint31nDistribution(t *testing.T) {
71+
const n = 10
72+
const iterations = 10000
73+
counts := make([]int, n)
74+
75+
for i := 0; i < iterations; i++ {
76+
v, err := readint31n(rand.Reader, n)
77+
if err != nil {
78+
t.Fatalf("readint31n error: %v", err)
79+
}
80+
if v < 0 || v >= n {
81+
t.Fatalf("readint31n returned out of range value: %d", v)
82+
}
83+
counts[v]++
84+
}
85+
86+
expected := float64(iterations) / float64(n)
87+
tolerance := expected * 0.2 // 20% tolerance
88+
89+
for i, count := range counts {
90+
if float64(count) < expected-tolerance || float64(count) > expected+tolerance {
91+
t.Errorf("readint31n distribution: value %d appeared %d times, expected ~%.0f (tolerance %.0f)",
92+
i, count, expected, tolerance)
93+
}
94+
}
95+
}
96+
97+
func TestReadint31nReadError(t *testing.T) {
98+
r := bytes.NewReader([]byte{1, 2}) // only 2 bytes, need 4
99+
_, err := readint31n(r, 10)
100+
if err == nil {
101+
t.Error("readint31n with short reader should return error")
102+
}
103+
}
104+
105+
type deterministicReader struct {
106+
values []uint32
107+
pos int
108+
}
109+
110+
func (d *deterministicReader) Read(p []byte) (n int, err error) {
111+
if d.pos >= len(d.values) {
112+
return 0, io.EOF
113+
}
114+
binary.LittleEndian.PutUint32(p, d.values[d.pos])
115+
d.pos++
116+
return 4, nil
117+
}
118+
119+
func TestReadint31nRejectionSampling(t *testing.T) {
120+
// Test that rejection sampling works correctly
121+
// For n=3, threshold = (2^32 - 3) % 3 = 1
122+
// Values where (v * n) & 0xFFFFFFFF < threshold should be rejected
123+
124+
// Create a reader that first returns a value that should be rejected,
125+
// then a value that should be accepted
126+
dr := &deterministicReader{
127+
values: []uint32{0, 0x55555555}, // First rejected, second accepted
128+
}
129+
130+
got, err := readint31n(dr, 3)
131+
if err != nil {
132+
t.Fatalf("readint31n error: %v", err)
133+
}
134+
// The second value 0x55555555 * 3 = 0xFFFFFFFF
135+
// High 32 bits = 0, so result should be 1
136+
expected := int32((uint64(0x55555555) * 3) >> 32)
137+
if got != expected {
138+
t.Errorf("readint31n with rejection = %d, want %d", got, expected)
139+
}
140+
}
141+
142+
func BenchmarkReadint31n(b *testing.B) {
143+
for i := 0; i < b.N; i++ {
144+
readint31n(rand.Reader, 62)
145+
}
146+
}

range.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
package rndstr
22

3-
// various constants for easy code generation
4-
3+
// Character sets for random string generation.
54
const (
6-
Alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
7-
Alnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
8-
Code = "ABCDEFGHJKLMNPRSTUVWXYZ23456789"
5+
// Alpha contains lowercase and uppercase ASCII letters.
6+
Alpha = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
7+
8+
// Alnum contains lowercase and uppercase ASCII letters plus digits.
9+
Alnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
10+
11+
// Code contains uppercase letters and digits, excluding ambiguous
12+
// characters (I, O, Q, 0, 1) for improved human readability.
13+
Code = "ABCDEFGHJKLMNPRSTUVWXYZ23456789"
14+
15+
// Password contains alphanumeric characters plus common special characters.
916
Password = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789&~#([-|_^@)]{}$*,?;.:/!<>"
1017
)

range_test.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package rndstr
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestAlpha(t *testing.T) {
9+
if len(Alpha) != 52 {
10+
t.Errorf("Alpha length = %d, want 52", len(Alpha))
11+
}
12+
13+
for _, c := range "abcdefghijklmnopqrstuvwxyz" {
14+
if !strings.ContainsRune(Alpha, c) {
15+
t.Errorf("Alpha missing lowercase %c", c)
16+
}
17+
}
18+
19+
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
20+
if !strings.ContainsRune(Alpha, c) {
21+
t.Errorf("Alpha missing uppercase %c", c)
22+
}
23+
}
24+
}
25+
26+
func TestAlnum(t *testing.T) {
27+
if len(Alnum) != 62 {
28+
t.Errorf("Alnum length = %d, want 62", len(Alnum))
29+
}
30+
31+
for _, c := range "abcdefghijklmnopqrstuvwxyz" {
32+
if !strings.ContainsRune(Alnum, c) {
33+
t.Errorf("Alnum missing lowercase %c", c)
34+
}
35+
}
36+
37+
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
38+
if !strings.ContainsRune(Alnum, c) {
39+
t.Errorf("Alnum missing uppercase %c", c)
40+
}
41+
}
42+
43+
for _, c := range "0123456789" {
44+
if !strings.ContainsRune(Alnum, c) {
45+
t.Errorf("Alnum missing digit %c", c)
46+
}
47+
}
48+
}
49+
50+
func TestCode(t *testing.T) {
51+
if len(Code) != 31 {
52+
t.Errorf("Code length = %d, want 31", len(Code))
53+
}
54+
55+
// Verify ambiguous characters are excluded
56+
ambiguous := "IOQ01"
57+
for _, c := range ambiguous {
58+
if strings.ContainsRune(Code, c) {
59+
t.Errorf("Code should not contain ambiguous character %c", c)
60+
}
61+
}
62+
63+
// Verify expected characters are present
64+
for _, c := range "ABCDEFGHJKLMNPRSTUVWXYZ23456789" {
65+
if !strings.ContainsRune(Code, c) {
66+
t.Errorf("Code missing expected character %c", c)
67+
}
68+
}
69+
}
70+
71+
func TestPassword(t *testing.T) {
72+
if len(Password) != 87 {
73+
t.Errorf("Password length = %d, want 87", len(Password))
74+
}
75+
76+
// Verify alphanumeric characters are present
77+
for _, c := range "abcdefghijklmnopqrstuvwxyz" {
78+
if !strings.ContainsRune(Password, c) {
79+
t.Errorf("Password missing lowercase %c", c)
80+
}
81+
}
82+
83+
for _, c := range "ABCDEFGHIJKLMNOPQRSTUVWXYZ" {
84+
if !strings.ContainsRune(Password, c) {
85+
t.Errorf("Password missing uppercase %c", c)
86+
}
87+
}
88+
89+
for _, c := range "0123456789" {
90+
if !strings.ContainsRune(Password, c) {
91+
t.Errorf("Password missing digit %c", c)
92+
}
93+
}
94+
95+
// Verify some special characters are present
96+
for _, c := range "&~#@!" {
97+
if !strings.ContainsRune(Password, c) {
98+
t.Errorf("Password missing special character %c", c)
99+
}
100+
}
101+
}
102+
103+
func TestNoDuplicates(t *testing.T) {
104+
charsets := map[string]string{
105+
"Alpha": Alpha,
106+
"Alnum": Alnum,
107+
"Code": Code,
108+
"Password": Password,
109+
}
110+
111+
for name, charset := range charsets {
112+
seen := make(map[rune]bool)
113+
for _, c := range charset {
114+
if seen[c] {
115+
t.Errorf("%s contains duplicate character %c", name, c)
116+
}
117+
seen[c] = true
118+
}
119+
}
120+
}

0 commit comments

Comments
 (0)