Skip to content

Commit 508eabf

Browse files
authored
feat(random): use crypto/rand for random string generator (#55)
1 parent d43909e commit 508eabf

File tree

2 files changed

+98
-7
lines changed

2 files changed

+98
-7
lines changed

random/random.go

+45-7
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package random
22

33
import (
4-
"math/rand"
4+
"bufio"
5+
"crypto/rand"
6+
"io"
57
"strings"
6-
"time"
8+
"sync"
79
)
810

911
type (
1012
Random struct {
13+
readerPool sync.Pool
1114
}
1215
)
1316

@@ -27,20 +30,55 @@ var (
2730
)
2831

2932
func New() *Random {
30-
rand.Seed(time.Now().UnixNano())
31-
return new(Random)
33+
// https://tip.golang.org/doc/go1.19#:~:text=Read%20no%20longer%20buffers%20random%20data%20obtained%20from%20the%20operating%20system%20between%20calls
34+
p := sync.Pool{New: func() interface{} {
35+
return bufio.NewReader(rand.Reader)
36+
}}
37+
return &Random{readerPool: p}
3238
}
3339

3440
func (r *Random) String(length uint8, charsets ...string) string {
3541
charset := strings.Join(charsets, "")
3642
if charset == "" {
3743
charset = Alphanumeric
3844
}
45+
46+
charsetLen := len(charset)
47+
if charsetLen > 255 {
48+
charsetLen = 255
49+
}
50+
maxByte := 255 - (256 % charsetLen)
51+
52+
reader := r.readerPool.Get().(*bufio.Reader)
53+
defer r.readerPool.Put(reader)
54+
3955
b := make([]byte, length)
40-
for i := range b {
41-
b[i] = charset[rand.Int63()%int64(len(charset))]
56+
rs := make([]byte, length+(length/4)) // perf: avoid read from rand.Reader many times
57+
var i uint8 = 0
58+
59+
// security note:
60+
// we can't just simply do b[i]=charset[rb%byte(charsetLen)],
61+
// for example, when charsetLen is 52, and rb is [0, 255], 256 = 52 * 4 + 48.
62+
// this will make the first 48 characters more possibly to be generated then others.
63+
// so we have to skip bytes when rb > maxByte
64+
65+
for {
66+
_, err := io.ReadFull(reader, rs)
67+
if err != nil {
68+
panic("unexpected error happened when reading from bufio.NewReader(crypto/rand.Reader)")
69+
}
70+
for _, rb := range rs {
71+
if rb > byte(maxByte) {
72+
// Skip this number to avoid bias.
73+
continue
74+
}
75+
b[i] = charset[rb%byte(charsetLen)]
76+
i++
77+
if i == length {
78+
return string(b)
79+
}
80+
}
4281
}
43-
return string(b)
4482
}
4583

4684
func String(length uint8, charsets ...string) string {

random/random_test.go

+53
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,63 @@ import (
55
"testing"
66

77
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
89
)
910

1011
func Test(t *testing.T) {
1112
assert.Len(t, String(32), 32)
1213
r := New()
1314
assert.Regexp(t, regexp.MustCompile("[0-9]+$"), r.String(8, Numeric))
1415
}
16+
17+
func TestRandomString(t *testing.T) {
18+
var testCases = []struct {
19+
name string
20+
whenLength uint8
21+
expect string
22+
}{
23+
{
24+
name: "ok, 16",
25+
whenLength: 16,
26+
},
27+
{
28+
name: "ok, 32",
29+
whenLength: 32,
30+
},
31+
}
32+
33+
for _, tc := range testCases {
34+
t.Run(tc.name, func(t *testing.T) {
35+
uid := String(tc.whenLength, Alphabetic)
36+
assert.Len(t, uid, int(tc.whenLength))
37+
})
38+
}
39+
}
40+
41+
func TestRandomStringBias(t *testing.T) {
42+
t.Parallel()
43+
const slen = 33
44+
const loop = 100000
45+
46+
counts := make(map[rune]int)
47+
var count int64
48+
49+
for i := 0; i < loop; i++ {
50+
s := String(slen, Alphabetic)
51+
require.Equal(t, slen, len(s))
52+
for _, b := range s {
53+
counts[b]++
54+
count++
55+
}
56+
}
57+
58+
require.Equal(t, len(Alphabetic), len(counts))
59+
60+
avg := float64(count) / float64(len(counts))
61+
for k, n := range counts {
62+
diff := float64(n) / avg
63+
if diff < 0.95 || diff > 1.05 {
64+
t.Errorf("Bias on '%c': expected average %f, got %d", k, avg, n)
65+
}
66+
}
67+
}

0 commit comments

Comments
 (0)