From 9bf6cb3539f53674f19a41f6d885abf1d58d4b71 Mon Sep 17 00:00:00 2001 From: Peter Kurfer Date: Tue, 25 Apr 2023 15:56:11 +0200 Subject: [PATCH] initial version --- .gitignore | 1 + api.go | 9 ++++ go.mod | 3 ++ helpers.go | 91 ++++++++++++++++++++++++++++++++++ helpers_test.go | 84 ++++++++++++++++++++++++++++++++ options.go | 126 ++++++++++++++++++++++++++++++++++++++++++++++++ pwgen.go | 51 ++++++++++++++++++++ pwgen_test.go | 91 ++++++++++++++++++++++++++++++++++ 8 files changed, 456 insertions(+) create mode 100644 api.go create mode 100644 go.mod create mode 100644 helpers.go create mode 100644 helpers_test.go create mode 100644 options.go create mode 100644 pwgen.go create mode 100644 pwgen_test.go diff --git a/.gitignore b/.gitignore index adf8f72..ebe7af3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ # Go workspace file go.work +.idea/ diff --git a/api.go b/api.go new file mode 100644 index 0000000..ee9056c --- /dev/null +++ b/api.go @@ -0,0 +1,9 @@ +package pwgen + +type GeneratorOption interface { + ApplyToOptions(options *options) +} + +type Generator interface { + Generate(opts ...GeneratorOption) (string, error) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ddab0a0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module code.icb4dc0.de/prskr/go-pwgen + +go 1.20 diff --git a/helpers.go b/helpers.go new file mode 100644 index 0000000..835aa4a --- /dev/null +++ b/helpers.go @@ -0,0 +1,91 @@ +package pwgen + +func runeFor(alphabet []rune, prng Int32n) rune { + return alphabet[prng.Int31n(int32(len(alphabet)))] +} + +func alphabet(start, end rune) (result []rune) { + length := end - start + 1 + result = make([]rune, length) + for i := int32(0); i < length; i++ { + result[i] = start + i + } + + return result +} + +func merge[T any](slices ...[]T) []T { + var totalLength int + for i := range slices { + totalLength += len(slices[i]) + } + + result := make([]T, totalLength) + + var offset int + for i := range slices { + copy(result[offset:], slices[i]) + offset += len(slices[i]) + } + + return result +} + +func shuffle[T comparable](in []T, prng Int32n) (result []T) { + if !canShuffle(in) { + return in + } + + for { + result = make([]T, 0, len(in)) + temp := make([]T, len(in)) + copy(temp, in) + + for len(temp) > 0 { + var i int32 + for { + i = prng.Int31n(int32(len(temp))) + if i != int32(len(result)) { + break + } + } + result = append(result, temp[i]) + copy(temp[i:], temp[i+1:]) + temp = temp[:len(temp)-1] + } + + if !sequenceEqual(in, result) { + return result + } + } +} + +func canShuffle[T comparable](in []T) bool { + if len(in) < 2 { + return false + } + + uniq := make(map[T]bool) + for _, r := range in { + uniq[r] = true + if len(uniq) > 1 { + return true + } + } + + return false +} + +func sequenceEqual[T comparable](first, second []T) bool { + if len(first) != len(second) { + return false + } + + for i := range first { + if first[i] != second[i] { + return false + } + } + + return true +} diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..981f3ec --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,84 @@ +package pwgen + +import ( + "math/rand" + "reflect" + "testing" + "unsafe" +) + +func Test_alphabet(t *testing.T) { + type args struct { + start rune + end rune + } + tests := []struct { + name string + args args + wantResult []rune + }{ + { + name: "Lowercase runes", + args: args{ + start: 'a', + end: 'z', + }, + //lowercase runes a to z + wantResult: []rune{'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'}, + }, + { + name: "Uppercase runes", + args: args{ + start: 'A', + end: 'Z', + }, + wantResult: []rune("ABCDEFGHIJKLMNOPQRSTUVWXYZ"), + }, + { + name: "Digit runes", + args: args{ + start: '0', + end: '9', + }, + wantResult: []rune("0123456789"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotResult := alphabet(tt.args.start, tt.args.end); !reflect.DeepEqual(gotResult, tt.wantResult) { + t.Errorf("alphabet() = %v, want %v", gotResult, tt.wantResult) + } + }) + } +} + +func Fuzz_shuffle(f *testing.F) { + defaultPrng := rand.New(rand.NewSource(1)) + + f.Add("hello, world") + f.Add("a") + f.Add("aa") + f.Add("abcdef") + f.Add("01234") + + f.Fuzz(func(t *testing.T, input string) { + in := []rune(input) + out := shuffle(in, defaultPrng) + + if sliceReferenceEqual(in, out) { + t.Logf("returned the same input") + return + } + + if len(input) > 1 && string(out) == input { + t.Errorf("shuffle() = %s, input %s", string(out), input) + } + }) +} + +func sliceReferenceEqual[T any](a, b []T) bool { + ah := (*reflect.SliceHeader)(unsafe.Pointer(&a)) + bh := (*reflect.SliceHeader)(unsafe.Pointer(&b)) + + return ah.Data == bh.Data +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..38abfd1 --- /dev/null +++ b/options.go @@ -0,0 +1,126 @@ +package pwgen + +import ( + "errors" + "math/rand" + "time" +) + +const ( + DefaultLength = 20 + DefaultSpecialsAlphabet = `!?~@#$%^&*()-+={}[]\/{}|<>` +) + +var ErrLengthOverflow = errors.New("length overflow") + +type generatorOptionFunc func(options *options) + +func (f generatorOptionFunc) ApplyToOptions(options *options) { + f(options) +} + +func WithLength(length uint) GeneratorOption { + return generatorOptionFunc(func(options *options) { + options.Length = length + }) +} + +func WithLetters(letters uint) GeneratorOption { + return generatorOptionFunc(func(options *options) { + options.Letters = letters + }) +} + +func WithUppercase(uppercase uint) GeneratorOption { + return generatorOptionFunc(func(options *options) { + options.Uppercase = uppercase + }) +} + +func WithLowercase(lowercase uint) GeneratorOption { + return generatorOptionFunc(func(options *options) { + options.Lowercase = lowercase + }) +} + +func WithDigits(digits uint) GeneratorOption { + return generatorOptionFunc(func(options *options) { + options.Digits = digits + }) +} + +func WithSpecials(specials uint) GeneratorOption { + return generatorOptionFunc(func(options *options) { + options.Specials = specials + }) +} + +func WithSpecialsAlphabet(specialsAlphabet string) GeneratorOption { + return generatorOptionFunc(func(options *options) { + if specialsAlphabet == "" { + specialsAlphabet = DefaultSpecialsAlphabet + } + options.SpecialsAlphabet = specialsAlphabet + }) +} + +func WithPRNG(prng Int32n) GeneratorOption { + return generatorOptionFunc(func(options *options) { + options.PRNG = prng + }) +} + +func defaultOptions(opts ...GeneratorOption) *options { + o := &options{ + PRNG: rand.New(rand.NewSource(time.Now().Unix())), + Length: DefaultLength, + Letters: 0, + Digits: 3, + Uppercase: 3, + Lowercase: 3, + Specials: 3, + SpecialsAlphabet: DefaultSpecialsAlphabet, + } + + for i := range opts { + opts[i].ApplyToOptions(o) + } + + return o +} + +type Int32n interface { + Int31n(n int32) int32 +} + +type options struct { + PRNG Int32n + Length uint + Letters uint + Digits uint + Uppercase uint + Lowercase uint + Specials uint + SpecialsAlphabet string +} + +func (o options) effectiveLength() (uint, error) { + var length uint + + for _, i := range []uint{o.Digits, o.Lowercase, o.Uppercase, o.Specials} { + if length+i < length { + return 0, ErrLengthOverflow + } + length += i + } + + if upperAndLower := o.Lowercase + o.Uppercase; upperAndLower < o.Letters { + length += o.Letters - upperAndLower + } + + if length > o.Length { + return length, nil + } + + return o.Length, nil +} diff --git a/pwgen.go b/pwgen.go new file mode 100644 index 0000000..9f7b42f --- /dev/null +++ b/pwgen.go @@ -0,0 +1,51 @@ +package pwgen + +var ( + _ Generator = (*DefaultGenerator)(nil) + Default DefaultGenerator + lowerCase = alphabet('a', 'z') + upperCase = alphabet('A', 'Z') + letters = merge(lowerCase, upperCase) + digits = alphabet('0', '9') +) + +type DefaultGenerator struct { +} + +func (d DefaultGenerator) Generate(opts ...GeneratorOption) (string, error) { + compiledOptions := defaultOptions(opts...) + fullAlphabet := merge(lowerCase, upperCase, digits, []rune(compiledOptions.SpecialsAlphabet)) + + effectiveLength, err := compiledOptions.effectiveLength() + if err != nil { + return "", err + } + + generated := make([]rune, 0, effectiveLength) + + for i := uint(0); i < compiledOptions.Lowercase; i++ { + generated = append(generated, runeFor(lowerCase, compiledOptions.PRNG)) + } + + for i := uint(0); i < compiledOptions.Uppercase; i++ { + generated = append(generated, runeFor(upperCase, compiledOptions.PRNG)) + } + + for i := uint(0); i < compiledOptions.Digits; i++ { + generated = append(generated, runeFor(digits, compiledOptions.PRNG)) + } + + for i := uint(0); i < compiledOptions.Specials; i++ { + generated = append(generated, runeFor([]rune(compiledOptions.SpecialsAlphabet), compiledOptions.PRNG)) + } + + for i := compiledOptions.Lowercase + compiledOptions.Uppercase; i < compiledOptions.Letters; i++ { + generated = append(generated, runeFor(letters, compiledOptions.PRNG)) + } + + for i := uint(len(generated)); i < effectiveLength; i++ { + generated = append(generated, runeFor(fullAlphabet, compiledOptions.PRNG)) + } + + return string(shuffle(generated, compiledOptions.PRNG)), nil +} diff --git a/pwgen_test.go b/pwgen_test.go new file mode 100644 index 0000000..976e249 --- /dev/null +++ b/pwgen_test.go @@ -0,0 +1,91 @@ +package pwgen_test + +import ( + "code.icb4dc0.de/prskr/go-pwgen" + "errors" + "testing" +) + +func FuzzDefaultGenerator_Generate(f *testing.F) { + + f.Add(uint(0), uint(2), uint(2), uint(2), uint(2)) + f.Add(uint(5), uint(2), uint(2), uint(2), uint(2)) + f.Add(uint(3), uint(2), uint(2), uint(2), uint(2)) + f.Add(uint(3), uint(2), uint(2), uint(2), uint(0)) + f.Add(uint(3), uint(2), uint(2), uint(2), uint(5)) + f.Add(uint(10), uint(2), uint(2), uint(2), uint(5)) + + f.Fuzz(func(t *testing.T, letters, digits, uppercase, lowercase, specials uint) { + password, err := pwgen.Default.Generate( + pwgen.WithLetters(letters), + pwgen.WithDigits(digits), + pwgen.WithUppercase(uppercase), + pwgen.WithLowercase(lowercase), + pwgen.WithSpecials(specials), + ) + + if err != nil { + if errors.Is(err, pwgen.ErrLengthOverflow) { + t.Logf("pwgen.ErrLengthOverflow") + return + } + t.Fatal(err) + } + + if matchesCriteria[rune]([]rune(password), containsDigit) < digits { + t.Errorf("password %q does not contain %d digits", password, digits) + } + + if matchesCriteria[rune]([]rune(password), containsLetter) < letters { + t.Errorf("password %q does not contain %d letters", password, letters) + } + + if matchesCriteria[rune]([]rune(password), containsUppercase) < uppercase { + t.Errorf("password %q does not contain %d uppercase", password, uppercase) + } + + if matchesCriteria[rune]([]rune(password), containsLowercase) < lowercase { + t.Errorf("password %q does not contain %d lowercase", password, lowercase) + } + + if matchesCriteria[rune]([]rune(password), containsSpecials) < specials { + t.Errorf("password %q does not contain %d specials", password, specials) + } + }) +} + +func matchesCriteria[T any](s []T, criteria func(T) bool) uint { + var found uint + for i := range s { + if criteria(s[i]) { + found += 1 + } + } + return found +} + +func containsLetter(r rune) bool { + return r >= 'A' && r <= 'z' +} + +func containsDigit(r rune) bool { + return r >= '0' && r <= '9' +} + +func containsSpecials(r rune) bool { + for i := range pwgen.DefaultSpecialsAlphabet { + if r == rune(pwgen.DefaultSpecialsAlphabet[i]) { + return true + } + } + + return false +} + +func containsLowercase(r rune) bool { + return r >= 'a' && r <= 'z' +} + +func containsUppercase(r rune) bool { + return r >= 'A' && r <= 'Z' +}