From 597fe535d32763f6b892e376bed4f2030c0aa45f Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Wed, 20 Jan 2021 02:57:34 +0000 Subject: [PATCH] feat(rands): initial implementation --- .gitignore | 4 + .golangci.yml | 78 +++++ LICENSE | 20 ++ Makefile | 173 +++++++++++ README.md | 82 +++++- bytes.go | 14 + bytes_example_test.go | 12 + bytes_test.go | 27 ++ go.mod | 5 + go.sum | 11 + ints.go | 37 +++ ints_example_test.go | 17 ++ ints_test.go | 154 ++++++++++ rands.go | 11 + rands_test.go | 18 ++ strings.go | 246 ++++++++++++++++ strings_example_test.go | 72 +++++ strings_test.go | 623 ++++++++++++++++++++++++++++++++++++++++ 18 files changed, 1603 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 bytes.go create mode 100644 bytes_example_test.go create mode 100644 bytes_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 ints.go create mode 100644 ints_example_test.go create mode 100644 ints_test.go create mode 100644 rands.go create mode 100644 rands_test.go create mode 100644 strings.go create mode 100644 strings_example_test.go create mode 100644 strings_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2663299 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/*.tidy-check +/bin/* +/coverage.out +/output.txt diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b5356f5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,78 @@ +linters-settings: + funlen: + lines: 100 + statements: 150 + gocyclo: + min-complexity: 20 + golint: + min-confidence: 0 + govet: + check-shadowing: true + enable-all: true + lll: + line-length: 80 + tab-width: 4 + maligned: + suggest-new: true + misspell: + locale: US + +linters: + disable-all: true + enable: + - bodyclose + - deadcode + - depguard + - dupl + - errcheck + - funlen + - gochecknoinits + - goconst + - gocritic + - gocyclo + - goerr113 + - goimports + - golint + - goprintffuncname + - gosec + - gosimple + - govet + - ineffassign + - lll + - misspell + - nakedret + - nlreturn + - noctx + - nolintlint + - scopelint + - sqlclosecheck + - staticcheck + - structcheck + - typecheck + - unconvert + - unused + - varcheck + - whitespace + +issues: + include: + # - EXC0002 # disable excluding of issues about comments from golint + exclude: + - Using the variable on range scope `tt` in function literal + - Using the variable on range scope `tc` in function literal + exclude-rules: + - path: "_test\\.go" + linters: + - funlen + - dupl + - source: "^//go:generate " + linters: + - lll + - source: "`json:" + linters: + - lll + +run: + timeout: 2m + allow-parallel-runners: true + modules-download-mode: readonly diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9dcb8ab --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2021 Jim Myhrberg + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1598645 --- /dev/null +++ b/Makefile @@ -0,0 +1,173 @@ +GOMODNAME := $(shell grep 'module' go.mod | sed -e 's/^module //') +SOURCES := $(shell find . -name "*.go" -or -name "go.mod" -or -name "go.sum" \ + -or -name "Makefile") + +# Verbose output +ifdef VERBOSE +V = -v +endif + +# +# Environment +# + +BINDIR := bin +TOOLDIR := $(BINDIR)/tools + +# Global environment variables for all targets +SHELL ?= /bin/bash +SHELL := env \ + GO111MODULE=on \ + GOBIN=$(CURDIR)/$(TOOLDIR) \ + CGO_ENABLED=1 \ + PATH='$(CURDIR)/$(BINDIR):$(CURDIR)/$(TOOLDIR):$(PATH)' \ + $(SHELL) + +# +# Defaults +# + +# Default target +.DEFAULT_GOAL := test + +# +# Tools +# + +TOOLS += $(TOOLDIR)/gobin +gobin: $(TOOLDIR)/gobin +$(TOOLDIR)/gobin: + GO111MODULE=off go get -u github.com/myitcv/gobin + +# external tool +define tool # 1: binary-name, 2: go-import-path +TOOLS += $(TOOLDIR)/$(1) + +.PHONY: $(1) +$(1): $(TOOLDIR)/$(1) + +$(TOOLDIR)/$(1): $(TOOLDIR)/gobin Makefile + gobin $(V) "$(2)" +endef + +$(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc)) +$(eval $(call tool,gofumports,mvdan.cc/gofumpt/gofumports)) +$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.35)) +$(eval $(call tool,gomod,github.com/Helcaraxan/gomod)) + +.PHONY: tools +tools: $(TOOLS) + +# +# Development +# + +TEST ?= $$(go list ./... | grep -v 'vendor') +BENCH ?= . + +.PHONY: clean +clean: + rm -f $(TOOLS) + rm -f ./coverage.out ./go.mod.tidy-check ./go.sum.tidy-check + +.PHONY: test +test: + go test $(V) -count=1 $(TESTARGS) $(TEST) + +.PHONY: test-deps +test-deps: + go test all + +.PHONY: lint +lint: golangci-lint + GOGC=off golangci-lint $(V) run + +.PHONY: format +format: gofumports + gofumports -w . + +.SILENT: bench +.PHONY: bench +bench: + go test $(V) -count=1 -bench=$(BENCH) $(TESTARGS) $(TEST) + +# +# Coverage +# + +.PHONY: cov +cov: coverage.out + +.PHONY: cov-html +cov-html: coverage.out + go tool cover -html=./coverage.out + +.PHONY: cov-func +cov-func: coverage.out + go tool cover -func=./coverage.out + +coverage.out: $(SOURCES) + go test $(V) -covermode=count -coverprofile=./coverage.out ./... + +# +# Dependencies +# + +.PHONY: deps +deps: + $(info Downloading dependencies) + go mod download + +.PHONY: deps-update +deps-update: + $(info Downloading dependencies) + go get -u ./... + +.PHONY: deps-analyze +deps-analyze: gomod + gomod analyze + +.PHONY: tidy +tidy: + go mod tidy $(V) + +.PHONY: verify +verify: + go mod verify + +.SILENT: check-tidy +.PHONY: check-tidy +check-tidy: + cp go.mod go.mod.tidy-check + cp go.sum go.sum.tidy-check + go mod tidy + ( \ + diff go.mod go.mod.tidy-check && \ + diff go.sum go.sum.tidy-check && \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum \ + ) || ( \ + rm -f go.mod go.sum && \ + mv go.mod.tidy-check go.mod && \ + mv go.sum.tidy-check go.sum; \ + exit 1 \ + ) + +# +# Documentation +# + +# Serve docs +.PHONY: docs +docs: godoc + $(info serviing docs on http://127.0.0.1:6060/pkg/$(GOMODNAME)/) + @godoc -http=127.0.0.1:6060 + +# +# Release +# + +.PHONY: new-version +new-version: + npx standard-version diff --git a/README.md b/README.md index abc7c2b..68cf072 100644 --- a/README.md +++ b/README.md @@ -1 +1,81 @@ -# rands +

+ rands +

+ +

+ + Go package providing a suite of functions that use `crypto/rand` to generate + cryptographically secure random strings in various formats, as well as ints + and bytes. + +

+ +

+ + Go Reference + + + GitHub tag (latest SemVer) + + + Actions Status + + + Coverage + + + + + + GitHub pull requests + + + License Status + +

+ +```go +s, _ := rands.Base64(16) // => CYxqEdUB1Rzno3SyZu2g/g== +s, _ := rands.Base64URL(16) // => zlqw9aFqcFggbk2asn3_aQ +s, _ := rands.Hex(16) // => 956e2ec9e7f19ddd58bb935826926531 +s, _ := rands.Alphanumeric(16) // => Fvk1PkrmG5crgOjT +s, _ := rands.Alphabetic(16) // => XEJIzcZufHkuUmRM +s, _ := rands.Upper(16) // => UMAGAFPPNDRGLUPZ +s, _ := rands.UpperNumeric(16) // => DF0CQS0TK9CPUO3E +s, _ := rands.Lower(16) // => ocsmggykzrxzfwgt +s, _ := rands.LowerNumeric(16) // => rwlv7a1p7klqffs5 +s, _ := rands.Numeric(16) // => 9403373143598295 + +s, _ := rands.String(16, "abcdefABCDEF") // => adCDCaDEdeffeDeb +s, _ := rands.UnicodeString(16, []rune("九七二人入八力十下三千上口土夕大")) // => 下下口九力下土夕下土八上二夕大三 + +s, _ := rands.DNSLabel(16) // => z0ij9o8qkbs0ru-h + +n, _ := rands.Int(2147483647) // => 1334400235 +n, _ := rands.Int64(int64(9223372036854775807)) // => 8256935979116161233 + +b, _ := rands.Bytes(8) // => [0 220 137 243 135 204 34 63] +``` + +## Import + +``` +import "github.com/jimeh/rands" +``` + +## Documentation + +Please see the +[Go Reference](https://pkg.go.dev/github.com/jimeh/rands#section-documentation) +for documentation and examples. + +## Benchmarks + +Benchmark reports and graphs are available here: +https://jimeh.me/rands/dev/bench/ + +## License + +[MIT](https://github.com/jimeh/rands/blob/master/LICENSE) diff --git a/bytes.go b/bytes.go new file mode 100644 index 0000000..9b3382e --- /dev/null +++ b/bytes.go @@ -0,0 +1,14 @@ +package rands + +import "crypto/rand" + +// Bytes generates a byte slice of n number of random bytes. +func Bytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + + return b, nil +} diff --git a/bytes_example_test.go b/bytes_example_test.go new file mode 100644 index 0000000..b0b8c90 --- /dev/null +++ b/bytes_example_test.go @@ -0,0 +1,12 @@ +package rands_test + +import ( + "fmt" + + "github.com/jimeh/rands" +) + +func ExampleBytes() { + b, _ := rands.Bytes(8) + fmt.Printf("%+v\n", b) // => [0 220 137 243 135 204 34 63] +} diff --git a/bytes_test.go b/bytes_test.go new file mode 100644 index 0000000..f02769a --- /dev/null +++ b/bytes_test.go @@ -0,0 +1,27 @@ +package rands + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBytes(t *testing.T) { + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Bytes(tt.n) + + assert.Len(t, got, tt.n) + }) + } +} + +func BenchmarkBytes(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Bytes(tt.n) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7e5294a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/jimeh/rands + +go 1.15 + +require github.com/stretchr/testify v1.7.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..acb88a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/ints.go b/ints.go new file mode 100644 index 0000000..02925e5 --- /dev/null +++ b/ints.go @@ -0,0 +1,37 @@ +package rands + +import ( + "crypto/rand" + "fmt" + "math/big" +) + +var errInvalidMaxInt = fmt.Errorf("%w: max cannot be less than 1", errBase) + +// Int generates a random int ranging between 0 and max. +func Int(max int) (int, error) { + if max < 1 { + return 0, errInvalidMaxInt + } + + r, err := rand.Int(rand.Reader, big.NewInt(int64(max))) + if err != nil { + return 0, err + } + + return int(r.Int64()), nil +} + +// Int64 generates a random int64 ranging between 0 and max. +func Int64(max int64) (int64, error) { + if max < 1 { + return 0, errInvalidMaxInt + } + + r, err := rand.Int(rand.Reader, big.NewInt(max)) + if err != nil { + return 0, err + } + + return r.Int64(), nil +} diff --git a/ints_example_test.go b/ints_example_test.go new file mode 100644 index 0000000..78b43dd --- /dev/null +++ b/ints_example_test.go @@ -0,0 +1,17 @@ +package rands_test + +import ( + "fmt" + + "github.com/jimeh/rands" +) + +func ExampleInt() { + n, _ := rands.Int(2147483647) + fmt.Printf("%d\n", n) // => 1334400235 +} + +func ExampleInt64() { + n, _ := rands.Int64(int64(9223372036854775807)) + fmt.Printf("%d\n", n) // => 8256935979116161233 +} diff --git a/ints_test.go b/ints_test.go new file mode 100644 index 0000000..25d9f52 --- /dev/null +++ b/ints_test.go @@ -0,0 +1,154 @@ +package rands + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +var testIntCases = []struct { + name string + max int + errIs error + errStr string +}{ + { + name: "n=-2394345", + max: -2394345, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-409600", + max: -409600, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-1024", + max: -1024, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-128", + max: -128, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-32", + max: -32, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-16", + max: -16, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-8", + max: -8, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-7", + max: -7, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-2", + max: -2, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=-1", + max: -1, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + { + name: "n=0", + max: 0, + errIs: errInvalidMaxInt, + errStr: "rands: max cannot be less than 1", + }, + {name: "n=1", max: 1}, + {name: "n=2", max: 2}, + {name: "n=7", max: 7}, + {name: "n=8", max: 8}, + {name: "n=16", max: 16}, + {name: "n=32", max: 32}, + {name: "n=128", max: 128}, + {name: "n=1024", max: 1024}, + {name: "n=409600", max: 409600}, + {name: "n=2394345", max: 2394345}, +} + +func TestInt(t *testing.T) { + for _, tt := range testIntCases { + t.Run(tt.name, func(t *testing.T) { + got, err := Int(tt.max) + + if tt.errIs == nil || tt.errStr == "" { + assert.GreaterOrEqual(t, got, 0) + assert.LessOrEqual(t, got, tt.max) + } + + if tt.errIs != nil { + assert.True(t, errors.Is(err, errInvalidMaxInt)) + } + + if tt.errStr != "" { + assert.EqualError(t, err, tt.errStr) + } + }) + } +} + +func BenchmarkInt(b *testing.B) { + for _, tt := range testIntCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Int(tt.max) + } + }) + } +} + +func TestInt64(t *testing.T) { + for _, tt := range testIntCases { + t.Run(tt.name, func(t *testing.T) { + got, err := Int64(int64(tt.max)) + + if tt.errIs == nil || tt.errStr == "" { + assert.GreaterOrEqual(t, got, int64(0)) + assert.LessOrEqual(t, got, int64(tt.max)) + } + + if tt.errIs != nil { + assert.True(t, errors.Is(err, errInvalidMaxInt)) + } + + if tt.errStr != "" { + assert.EqualError(t, err, tt.errStr) + } + }) + } +} + +func BenchmarkInt64(b *testing.B) { + for _, tt := range testIntCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Int64(int64(tt.max)) + } + }) + } +} diff --git a/rands.go b/rands.go new file mode 100644 index 0000000..882a2fa --- /dev/null +++ b/rands.go @@ -0,0 +1,11 @@ +// Package rands provides a suite of functions that use crypto/rand to generate +// cryptographically secure random strings in various formats, as well as ints +// and bytes. +// +// All functions which produce strings from a alphabet of characters uses +// rand.Int() to ensure a uniform distribution of all possible values. +package rands + +import "errors" + +var errBase = errors.New("rands") diff --git a/rands_test.go b/rands_test.go new file mode 100644 index 0000000..653a7f2 --- /dev/null +++ b/rands_test.go @@ -0,0 +1,18 @@ +package rands + +var testCases = []struct { + name string + n int +}{ + {name: "n=0", n: 0}, + {name: "n=1", n: 1}, + {name: "n=2", n: 2}, + {name: "n=7", n: 7}, + {name: "n=8", n: 8}, + {name: "n=16", n: 16}, + {name: "n=32", n: 32}, + {name: "n=128", n: 128}, + {name: "n=1024", n: 1024}, + {name: "n=409600", n: 409600}, + {name: "n=2394345", n: 2394345}, +} diff --git a/strings.go b/strings.go new file mode 100644 index 0000000..ee08484 --- /dev/null +++ b/strings.go @@ -0,0 +1,246 @@ +package rands + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + "unicode" +) + +const ( + upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lowerChars = "abcdefghijklmnopqrstuvwxyz" + numericChars = "0123456789" + lowerNumericChars = lowerChars + numericChars + upperNumericChars = upperChars + numericChars + alphabeticChars = upperChars + lowerChars + alphanumericChars = alphabeticChars + numericChars + dnsLabelChars = lowerNumericChars + "-" +) + +var ( + errNonASCIIAlphabet = fmt.Errorf( + "%w: alphabet contains non-ASCII characters", errBase, + ) + + errDNSLabelLength = fmt.Errorf( + "%w: DNS labels must be between 1 and 63 characters in length", errBase, + ) +) + +// Base64 generates a random base64 encoded string of n number of bytes. +// +// Length of the returned string is about one third greater than the value of n, +// and it may contain characters A-Z, a-z, 0-9, "+", "/", and "=". +func Base64(n int) (string, error) { + b, err := Bytes(n) + if err != nil { + return "", err + } + + return base64.StdEncoding.EncodeToString(b), nil +} + +// Base64URL generates a URL-safe un-padded random base64 encoded string of n +// number of bytes. +// +// Length of the returned string is about one third greater than the value of n, +// and it may contain characters A-Z, a-z, 0-9, "-", and "_". +func Base64URL(n int) (string, error) { + b, err := Bytes(n) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// Hex generates a random hexadecimal encoded string of n number of bytes. +// +// Length of the returned string is twice the value of n, and it may contain +// characters 0-9 and a-f. +func Hex(n int) (string, error) { + b, err := Bytes(n) + if err != nil { + return "", err + } + + return hex.EncodeToString(b), nil +} + +// Alphanumeric generates a random alphanumeric string of n length. +// +// The returned string may contain A-Z, a-z, and 0-9. +func Alphanumeric(n int) (string, error) { + return String(n, alphanumericChars) +} + +// Alphabetic generates a random alphabetic string of n length. +// +// The returned string may contain A-Z, and a-z. +func Alphabetic(n int) (string, error) { + return String(n, alphabeticChars) +} + +// Numeric generates a random numeric string of n length. +// +// The returned string may contain 0-9. +func Numeric(n int) (string, error) { + return String(n, numericChars) +} + +// Upper generates a random uppercase alphabetic string of n length. +// +// The returned string may contain A-Z. +func Upper(n int) (string, error) { + return String(n, upperChars) +} + +// UpperNumeric generates a random uppercase alphanumeric string of n length. +// +// The returned string may contain A-Z and 0-9. +func UpperNumeric(n int) (string, error) { + return String(n, upperNumericChars) +} + +// Lower generates a random lowercase alphabetic string of n length. +// +// The returned string may contain a-z. +func Lower(n int) (string, error) { + return String(n, lowerChars) +} + +// LowerNumeric generates a random lowercase alphanumeric string of n length. +// +// The returned string may contain A-Z and 0-9. +func LowerNumeric(n int) (string, error) { + return String(n, lowerNumericChars) +} + +// String generates a random string of n length using the given ASCII alphabet. +// +// The specified alphabet determines what characters are used in the returned +// random string. The alphabet can only contain ASCII characters, use +// UnicodeString() if you need a alphabet with Unicode characters. +func String(n int, alphabet string) (string, error) { + if !isASCII(alphabet) { + return "", errNonASCIIAlphabet + } + + l := big.NewInt(int64(len(alphabet))) + b := make([]byte, n) + for i := 0; i < n; i++ { + index, err := rand.Int(rand.Reader, l) + if err != nil { + return "", err + } + b[i] = alphabet[index.Int64()] + } + + return string(b), nil +} + +// UnicodeString generates a random string of n length using the given Unicode +// alphabet. +// +// The specified alphabet determines what characters are used in the returned +// random string. The length of the returned string will be n or greater +// depending on the byte-length of characters which were randomly selected from +// the alphabet. +func UnicodeString(n int, alphabet []rune) (string, error) { + l := big.NewInt(int64(len(alphabet))) + b := make([]rune, n) + for i := 0; i < n; i++ { + index, err := rand.Int(rand.Reader, l) + if err != nil { + return "", err + } + b[i] = alphabet[index.Int64()] + } + + return string(b), nil +} + +// DNSLabel returns a random string of n length in a DNS label compliant format +// as defined in RFC 1035, section 2.3.1, Preferred name syntax: +// https://tools.ietf.org/html/rfc1035#section-2.3.1 +// +// It also adheres to RFC 5891, section 4.2.3.1, Hyphen Restrictions: +// https://tools.ietf.org/html/rfc5891#section-4.2.3.1 +// +// In summary, the generated random string will: +// +// - be between 1 and 63 characters in length, other n values returns a error +// - first character will be one of a-z +// - last character will be one of a-z or 0-9 +// - in-between first and last characters consist of a-z, 0-9, or "-" +// - potentially contain two or more consecutive "-", except the 3rd and 4th +// characters, as that would violate RFC 5891. +func DNSLabel(n int) (string, error) { + switch { + case n < 1 || n > 63: + return "", errDNSLabelLength + case n == 1: + return String(1, lowerChars) + default: + // First character of a DNS label allows only a-z characters. + head, err := String(1, lowerChars) + if err != nil { + return "", err + } + + // Last character of a DNS label allows only a-z and 0-9 characters. + tail, err := String(1, lowerNumericChars) + if err != nil { + return "", err + } + + if n < 3 { + return head + tail, nil + } + + // The middle of a DNS label allows only a-z, 0-9, and "-" characters. + bodyLen := n - 2 + body := make([]byte, bodyLen) + var last byte + var l *big.Int + + for i := 0; i < bodyLen; i++ { + // Prevent two consecutive hyphens characters in positions 3 and 4, + // in accordance RFC 5891, section 4.2.3.1, Hyphen Restrictions: + // https://tools.ietf.org/html/rfc5891#section-4.2.3.1 + if i == 2 && last == byte(45) { + l = big.NewInt(int64(len(lowerNumericChars))) + } else { + l = big.NewInt(int64(len(dnsLabelChars))) + } + + index, err := rand.Int(rand.Reader, l) + if err != nil { + return "", err + } + + if i == 2 && last == byte(45) { + last = lowerNumericChars[index.Int64()] + } else { + last = dnsLabelChars[index.Int64()] + } + + body[i] = last + } + + return head + string(body) + tail, nil + } +} + +func isASCII(s string) bool { + for _, c := range s { + if c > unicode.MaxASCII { + return false + } + } + + return true +} diff --git a/strings_example_test.go b/strings_example_test.go new file mode 100644 index 0000000..9ca4380 --- /dev/null +++ b/strings_example_test.go @@ -0,0 +1,72 @@ +package rands_test + +import ( + "fmt" + + "github.com/jimeh/rands" +) + +func ExampleBase64() { + s, _ := rands.Base64(16) + fmt.Println(s) // => CYxqEdUB1Rzno3SyZu2g/g== +} + +func ExampleBase64URL() { + s, _ := rands.Base64URL(16) + fmt.Println(s) // => zlqw9aFqcFggbk2asn3_aQ +} + +func ExampleHex() { + s, _ := rands.Hex(16) + fmt.Println(s) // => 956e2ec9e7f19ddd58bb935826926531 +} + +func ExampleAlphanumeric() { + s, _ := rands.Alphanumeric(16) + fmt.Println(s) // => Fvk1PkrmG5crgOjT +} + +func ExampleAlphabetic() { + s, _ := rands.Alphabetic(16) + fmt.Println(s) // => XEJIzcZufHkuUmRM +} + +func ExampleUpper() { + s, _ := rands.Upper(16) + fmt.Println(s) // => UMAGAFPPNDRGLUPZ +} + +func ExampleUpperNumeric() { + s, _ := rands.UpperNumeric(16) + fmt.Println(s) // => DF0CQS0TK9CPUO3E +} + +func ExampleLower() { + s, _ := rands.Lower(16) + fmt.Println(s) // => ocsmggykzrxzfwgt +} + +func ExampleLowerNumeric() { + s, _ := rands.LowerNumeric(16) + fmt.Println(s) // => rwlv7a1p7klqffs5 +} + +func ExampleNumeric() { + s, _ := rands.Numeric(16) + fmt.Println(s) // => 9403373143598295 +} + +func ExampleString() { + s, _ := rands.String(16, "abcdefABCDEF") + fmt.Println(s) // => adCDCaDEdeffeDeb +} + +func ExampleUnicodeString() { + s, _ := rands.UnicodeString(16, []rune("九七二人入八力十下三千上口土夕大")) + fmt.Println(s) // => 下下口九力下土夕下土八上二夕大三 +} + +func ExampleDNSLabel() { + s, _ := rands.DNSLabel(16) + fmt.Println(s) // => z0ij9o8qkbs0ru-h +} diff --git a/strings_test.go b/strings_test.go new file mode 100644 index 0000000..9643c01 --- /dev/null +++ b/strings_test.go @@ -0,0 +1,623 @@ +package rands + +import ( + "encoding/base64" + "errors" + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHex(t *testing.T) { + allowed := "0123456789abcdef" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Hex(tt.n) + + assert.Len(t, got, tt.n*2) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkHex(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Hex(tt.n) + } + }) + } +} + +func TestBase64(t *testing.T) { + allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + "0123456789+/=" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Base64(tt.n) + + b, err := base64.StdEncoding.DecodeString(got) + require.NoError(t, err) + + assert.Len(t, b, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkBase64(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Base64(tt.n) + } + }) + } +} + +func TestBase64URL(t *testing.T) { + allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + "0123456789-_" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Base64URL(tt.n) + + b, err := base64.RawURLEncoding.DecodeString(got) + require.NoError(t, err) + + assert.Len(t, b, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkBase64URL(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Base64URL(tt.n) + } + }) + } +} + +func TestAlphanumeric(t *testing.T) { + allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Alphanumeric(tt.n) + + assert.Len(t, got, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkAlphanumeric(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Alphanumeric(tt.n) + } + }) + } +} + +func TestAlphabetic(t *testing.T) { + allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Alphabetic(tt.n) + + assert.Len(t, got, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkAlphabetic(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Alphabetic(tt.n) + } + }) + } +} + +func TestNumeric(t *testing.T) { + allowed := "0123456789" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Numeric(tt.n) + + assert.Len(t, got, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkNumeric(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Numeric(tt.n) + } + }) + } +} + +func TestUpper(t *testing.T) { + allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Upper(tt.n) + + assert.Len(t, got, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkUpper(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Upper(tt.n) + } + }) + } +} + +func TestUpperNumeric(t *testing.T) { + allowed := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := UpperNumeric(tt.n) + + assert.Len(t, got, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkUpperNumeric(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = UpperNumeric(tt.n) + } + }) + } +} + +func TestLower(t *testing.T) { + allowed := "abcdefghijklmnopqrstuvwxyz" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := Lower(tt.n) + + assert.Len(t, got, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkLower(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = Lower(tt.n) + } + }) + } +} + +func TestLowerNumeric(t *testing.T) { + allowed := "abcdefghijklmnopqrstuvwxyz0123456789" + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := LowerNumeric(tt.n) + + assert.Len(t, got, tt.n) + assertAllowedChars(t, allowed, got) + }) + } +} + +func BenchmarkLowerNumeric(b *testing.B) { + for _, tt := range testCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = LowerNumeric(tt.n) + } + }) + } +} + +var stringTestCases = []struct { + name string + n int + alphabet string + errIs error + errStr string +}{ + { + name: "greek", + n: 32, + alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" + + "άίόύώέϊϋΐΰΆΈΌΏΎΊ", + errIs: errNonASCIIAlphabet, + errStr: "rands: alphabet contains non-ASCII characters", + }, + { + name: "chinese", + n: 32, + alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" + + "时道出而要于就下得可你年生", + errIs: errNonASCIIAlphabet, + errStr: "rands: alphabet contains non-ASCII characters", + }, + { + name: "japanese", + n: 32, + alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" + + "手文日月木水火犬王正出本右四", + errIs: errNonASCIIAlphabet, + errStr: "rands: alphabet contains non-ASCII characters", + }, + { + name: "n=0", + n: 0, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=1", + n: 1, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=2", + n: 2, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=7", + n: 7, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=8", + n: 8, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=16", + n: 16, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=32", + n: 32, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=128", + n: 128, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=1024", + n: 1024, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=409600", + n: 409600, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "n=2394345", + n: 2394345, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-", + }, + { + name: "uppercase", + n: 16, + alphabet: "ABCDEFGHJKMNPRSTUVWXYZ", + }, + { + name: "lowercase", + n: 16, + alphabet: "abcdefghjkmnprstuvwxyz", + }, +} + +func TestString(t *testing.T) { + for _, tt := range stringTestCases { + t.Run(tt.name, func(t *testing.T) { + got, err := String(tt.n, tt.alphabet) + + if tt.errIs == nil || tt.errStr == "" { + assert.Len(t, []rune(got), tt.n) + assertAllowedChars(t, tt.alphabet, got) + } + + if tt.errIs != nil { + assert.True(t, errors.Is(err, errNonASCIIAlphabet)) + } + + if tt.errStr != "" { + assert.EqualError(t, err, tt.errStr) + } + }) + } +} + +func BenchmarkString(b *testing.B) { + for _, tt := range stringTestCases { + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = String(tt.n, tt.alphabet) + } + }) + } +} + +var unicodeStringTestCases = []struct { + name string + n int + alphabet string +}{ + { + name: "n=0", + n: 0, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=1", + n: 1, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=2", + n: 2, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=7", + n: 7, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=8", + n: 8, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=16", + n: 16, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=32", + n: 32, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=128", + n: 128, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=1024", + n: 1024, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=409600", + n: 409600, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "n=2394345", + n: 2394345, + alphabet: "αβγδεζηθικλμν时道出而要于就下得可你年" + + "手文日月木水火犬王正出本右", + }, + { + name: "latin", + n: 32, + alphabet: "ABCDEFGHJKMNPRSTUVWXYZabcdefghjkmnprstuvwxyz", + }, + { + name: "greek", + n: 32, + alphabet: "αβγδεζηθικλμνξοπρστυφχψωςΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΩ" + + "άίόύώέϊϋΐΰΆΈΌΏΎΊ", + }, + { + name: "chinese", + n: 32, + alphabet: "的一是不了人我在有他这为之大来以个中上们到说国和地也子" + + "时道出而要于就下得可你年生", + }, + { + name: "japanese", + n: 32, + alphabet: "一九七二人入八力十下三千上口土夕大女子小山川五天中六円" + + "手文日月木水火犬王正出本右四", + }, +} + +func TestUnicodeString(t *testing.T) { + for _, tt := range unicodeStringTestCases { + t.Run(tt.name, func(t *testing.T) { + got, _ := UnicodeString(tt.n, []rune(tt.alphabet)) + + assert.Len(t, []rune(got), tt.n) + assertAllowedChars(t, tt.alphabet, got) + }) + } +} + +func BenchmarkUnicodeString(b *testing.B) { + for _, tt := range stringTestCases { + alphabet := []rune(tt.alphabet) + + b.Run(tt.name, func(b *testing.B) { + for n := 0; n < b.N; n++ { + _, _ = UnicodeString(tt.n, alphabet) + } + }) + } +} + +func TestDNSLabel(t *testing.T) { + tests := []struct { + name string + n int + errIs error + errStr string + }{ + { + name: "n=-128", + n: -128, + errIs: errDNSLabelLength, + errStr: "rands: DNS labels must be between 1 and 63 characters " + + "in length", + }, + { + name: "n=0", + n: 0, + errIs: errDNSLabelLength, + errStr: "rands: DNS labels must be between 1 and 63 characters " + + "in length", + }, + {name: "n=1", n: 1}, + {name: "n=2", n: 2}, + {name: "n=3", n: 3}, + {name: "n=4", n: 4}, + {name: "n=5", n: 5}, + {name: "n=6", n: 6}, + {name: "n=7", n: 7}, + {name: "n=8", n: 8}, + {name: "n=16", n: 16}, + {name: "n=32", n: 32}, + {name: "n=63", n: 63}, + { + name: "n=64", + n: 64, + errIs: errDNSLabelLength, + errStr: "rands: DNS labels must be between 1 and 63 characters " + + "in length", + }, + { + name: "n=128", + n: 128, + errIs: errDNSLabelLength, + errStr: "rands: DNS labels must be between 1 and 63 characters " + + "in length", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // generate lots of labels to increase the chances of catching any + // obscure bugs + for i := 0; i < 10000; i++ { + got, err := DNSLabel(tt.n) + + if tt.errIs == nil || tt.errStr == "" { + require.Len(t, got, tt.n) + asserDNSLabel(t, got) + } + + if tt.errIs != nil { + require.True(t, errors.Is(err, errDNSLabelLength)) + } + + if tt.errStr != "" { + require.EqualError(t, err, tt.errStr) + } + } + }) + } +} + +// +// Helpers +// + +var ( + dnsLabelHeadRx = regexp.MustCompile(`^[a-z]$`) + dnsLabelBodyRx = regexp.MustCompile(`^[a-z0-9-]+$`) + dnsLabelTailRx = regexp.MustCompile(`^[a-z0-9]$`) +) + +func asserDNSLabel(t *testing.T, label string) { + require.LessOrEqualf(t, len(label), 63, + `DNS label "%s" is longer than 63 characters`, label, + ) + + require.GreaterOrEqualf(t, len(label), 1, + `DNS label "%s" is shorter than 1 character`, label, + ) + + if len(label) >= 1 { + require.Regexpf(t, dnsLabelHeadRx, string(label[0]), + `DNS label "%s" must start with a-z`, label, + ) + } + if len(label) >= 2 { + require.Regexpf(t, dnsLabelTailRx, string(label[len(label)-1]), + `DNS label "%s" must end with a-z0-9`, label, + ) + } + if len(label) >= 3 { + require.Regexpf(t, dnsLabelBodyRx, label[1:len(label)-1], + `DNS label "%s" body must only contain a-z0-9-`, label) + } + if len(label) >= 4 { + require.NotEqualf(t, "--", label[2:4], + `DNS label "%s" cannot contain "--" as 3rd and 4th char`, label, + ) + } +} + +func assertAllowedChars(t *testing.T, allowed string, s string) { + invalid := "" + for _, c := range s { + if !strings.Contains(allowed, string(c)) && + !strings.Contains(invalid, string(c)) { + invalid += string(c) + } + } + + assert.Truef( + t, len(invalid) == 0, "string contains invalid chars: %s", invalid, + ) +}