From 70fd82820a009fea76533eeb9cae24a42d279718 Mon Sep 17 00:00:00 2001 From: Ellis James Date: Thu, 7 Dec 2023 11:23:58 +0000 Subject: [PATCH] feat: Add GetFilteredWordList to asset, this allows us to filter the word list as we are creating the word list. Instead of creating an intermediate slice containing all words and creating another slice with filtered words feat!: add GenerateDigit to RNGService interface fix: issue where if you used a random seperator it wouldn't remove edge characters when it should have doc: add comments for public methods and other code where appropriate --- README.md | 50 +++++++++- asset/asset.go | 87 +++++++++++++---- config/config.go | 135 ++++++++++++++++----------- go.mod | 4 +- internal/stringcheck/string_check.go | 10 ++ service/padding.go | 85 ++++++++++++++--- service/padding_test.go | 70 ++++++++++++-- service/password_generator.go | 51 ++++++---- service/password_generator_test.go | 2 +- service/rng.go | 31 +++++- service/rng_test.go | 24 +++++ service/separator.go | 24 ++++- service/separator_test.go | 2 +- service/transformer.go | 55 ++++++++++- service/transformer_test.go | 16 +++- service/word_list.go | 29 +++--- service/word_list_test.go | 10 +- 17 files changed, 549 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index f410e0d..0a5fa4b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,51 @@ # libpass -Library code which can be used to create tools which generate memorable passwords, used by [mempass](https://github.com/eljamo/mempass) +A Go library specifically designed for generating secure and memorable passwords. It serves as the backbone for [mempass](https://https://github.com/eljamo/mempass) + +## Basic Usage + +``` +package main + +import ( + "fmt" + + "github.com/eljamo/libpass/v4/config" + "github.com/eljamo/libpass/v4/service" +) + +func main() { + specialCharacters := []string{ + "!", "@", "$", "%", "^", "&", "*", "-", "+", "=", ":", "|", "~", "?", "/", ".", ";", + } + config := &config.Config{ + WordList: "EN", + NumPasswords: 3, + NumWords: 3, + WordLengthMin: 4, + WordLengthMax: 8, + CaseTransform: "RANDOM", + SeparatorCharacter: "RANDOM", + SeparatorAlphabet: specialCharacters, + PaddingDigitsBefore: 2, + PaddingDigitsAfter: 2, + PaddingType: "FIXED", + PaddingCharacter: "RANDOM", + SymbolAlphabet: specialCharacters, + PaddingCharactersBefore: 2, + PaddingCharactersAfter: 2, + } + + svc, err := service.NewPasswordGeneratorService(config) + if err != nil { + fmt.Println(err) + } + + pws, err := svc.Generate() + if err != nil { + fmt.Println(err) + } + + fmt.Println(pws) +} +``` \ No newline at end of file diff --git a/asset/asset.go b/asset/asset.go index 92a8158..b139092 100644 --- a/asset/asset.go +++ b/asset/asset.go @@ -1,18 +1,19 @@ package asset import ( + "bufio" "embed" "encoding/json" "fmt" "os" + "path" "strings" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/config" ) //go:embed preset/* word_list/* -var Files embed.FS - +var files embed.FS var fileMap = map[string]map[string]string{ config.PresetKey: { config.AppleID: "appleid.json", @@ -42,53 +43,107 @@ var fileMap = map[string]map[string]string{ func keyToFile(key, fileType string) (string, bool) { file, ok := fileMap[fileType][strings.ToUpper(key)] + return file, ok } -func loadFileData(filePath string, readerFunc func(string) ([]byte, error)) (string, error) { +func loadJSONFileData(filePath string, readerFunc func(string) ([]byte, error)) (string, error) { data, err := readerFunc(filePath) if err != nil { - return "", fmt.Errorf("failed to read file '%s': %w", filePath, err) + return "", fmt.Errorf("failed to read file (%s): %w", filePath, err) } var parsed any if err := json.Unmarshal(data, &parsed); err != nil { - return "", fmt.Errorf("invalid JSON content in '%s': %w", filePath, err) + return "", fmt.Errorf("invalid JSON content in (%s): %w", filePath, err) } jsonData, err := json.Marshal(parsed) if err != nil { - return "", fmt.Errorf("error marshaling JSON data from '%s': %w", filePath, err) + return "", fmt.Errorf("marshaling JSON data for %s failed: %w", filePath, err) } return string(jsonData), nil } +// LoadJSONFile reads a JSON file from the given file path and returns its +// content as a string. It handles file reading and JSON unmarshalling. In case +// of any error during these operations, an error is returned func LoadJSONFile(filePath string) (string, error) { - return loadFileData(filePath, os.ReadFile) + return loadJSONFileData(filePath, os.ReadFile) } -func GetWordList(key string) ([]string, error) { +func getWordListFilePath(key string) (string, error) { fileName, ok := keyToFile(key, config.WordListKey) if !ok { - return nil, fmt.Errorf("invalid word list key '%s'", key) + return "", fmt.Errorf("invalid %s value (%s)", config.WordListKey, key) } - filePath := fmt.Sprintf("%s/%s", config.WordListKey, fileName) - data, err := Files.ReadFile(filePath) + return path.Join(config.WordListKey, fileName), nil +} + +// GetWordList retrieves a list of words from an embedded file identified by the +// given key. The method reads the file content, splits it by newline characters +// and returns the result as a slice of strings. If the file cannot be found or +// read, an error is returned. +func GetWordList(key string) ([]string, error) { + path, err := getWordListFilePath(key) if err != nil { - return nil, fmt.Errorf("failed to read embedded text file '%s': %w", filePath, err) + return nil, err + } + + data, err := files.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read embedded text file (%s): %w", path, err) } return strings.Split(string(data), "\n"), nil } +// GetFilteredWordList reads a word list from an embedded file identified by the +// given key, and filters the words based on the specified minimum and maximum +// length. It returns a slice of strings that meet the length criteria. If the +// file cannot be opened or read, or if an error occurs during scanning, an +// error is returned. +func GetFilteredWordList(key string, minLen int, maxLen int) ([]string, error) { + path, err := getWordListFilePath(key) + if err != nil { + return nil, err + } + + file, err := files.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + var wl []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if len(line) >= minLen && len(line) <= maxLen { + wl = append(wl, line) + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return wl, nil +} + +// GetJSONPreset reads a JSON preset file identified by the given key from +// embedded files. It returns the content of the JSON file as a string. If the +// file is not found, cannot be read, or contains invalid JSON, an error is +// returned. func GetJSONPreset(key string) (string, error) { fileName, ok := keyToFile(key, config.PresetKey) if !ok { - return "", fmt.Errorf("invalid JSON preset key '%s'", key) + return "", fmt.Errorf("invalid %s value (%s)", config.PresetKey, key) } - filePath := fmt.Sprintf("%s/%s", config.PresetKey, fileName) - return loadFileData(filePath, Files.ReadFile) + filePath := path.Join(config.PresetKey, fileName) + + return loadJSONFileData(filePath, files.ReadFile) } diff --git a/config/config.go b/config/config.go index 72fcc90..b03ab61 100644 --- a/config/config.go +++ b/config/config.go @@ -1,99 +1,130 @@ package config +// Config key const ( - PresetKey = "preset" - WordListKey = "word_list" + PresetKey string = "preset" + WordListKey string = "word_list" ) +// Word list constant const ( - All = "ALL" - DoctorWho = "DOCTOR_WHO" - EN = "EN" - ENSmall = "EN_SMALL" - GameOfThrones = "GAME_OF_THRONES" - HarryPotter = "HARRY_POTTER" - MiddleEarth = "MIDDLE_EARTH" - Pokemon = "POKEMON" - StarTrek = "STAR_TREK" - StarWars = "STAR_WARS" + All string = "ALL" + DoctorWho string = "DOCTOR_WHO" + EN string = "EN" + ENSmall string = "EN_SMALL" + GameOfThrones string = "GAME_OF_THRONES" + HarryPotter string = "HARRY_POTTER" + MiddleEarth string = "MIDDLE_EARTH" + Pokemon string = "POKEMON" + StarTrek string = "STAR_TREK" + StarWars string = "STAR_WARS" ) +// Preset constant const ( - AppleID = "APPLEID" - Default = "DEFAULT" - NTLM = "NTLM" - SecurityQ = "SECURITYQ" - Web16 = "WEB16" - Web16XKPasswd = "WEB16_XKPASSWD" - Web32 = "WEB32" - WiFi = "WIFI" - XKCD = "XKCD" - XKCDXKPasswd = "XKCD_XKPASSWD" + AppleID string = "APPLEID" + Default string = "DEFAULT" + NTLM string = "NTLM" + SecurityQ string = "SECURITYQ" + Web16 string = "WEB16" + Web16XKPasswd string = "WEB16_XKPASSWD" + Web32 string = "WEB32" + WiFi string = "WIFI" + XKCD string = "XKCD" + XKCDXKPasswd string = "XKCD_XKPASSWD" ) +// Shared constant const ( - None = "NONE" - Random = "RANDOM" + None string = "NONE" + Random string = "RANDOM" ) +// Case transform constant const ( - Alternate = "ALTERNATE" - AlternateLettercase = "ALTERNATE_LETTERCASE" - Capitalise = "CAPITALISE" - CapitaliseInvert = "CAPITALISE_INVERT" - Invert = "INVERT" // Same as CapitaliseInvert but reserved to maintain compatibility with xkpasswd.net generated configs - Lower = "LOWER" - LowerVowelUpperConsonant = "LOWER_VOWEL_UPPER_CONSONANT" - Sentence = "SENTENCE" - Upper = "UPPER" + Alternate string = "ALTERNATE" + AlternateLettercase string = "ALTERNATE_LETTERCASE" + Capitalise string = "CAPITALISE" + CapitaliseInvert string = "CAPITALISE_INVERT" + // The same as CapitaliseInvert but reserved to maintain compatibility with xkpasswd.net generated configs + Invert string = "INVERT" + Lower string = "LOWER" + LowerVowelUpperConsonant string = "LOWER_VOWEL_UPPER_CONSONANT" + Sentence string = "SENTENCE" + Upper string = "UPPER" ) +// Padding type constant const ( - Adaptive = "ADAPTIVE" - Fixed = "FIXED" + Adaptive string = "ADAPTIVE" + Fixed string = "FIXED" ) +// A slice of available presets var Preset = []string{ Default, AppleID, NTLM, SecurityQ, Web16, Web16XKPasswd, Web32, WiFi, XKCD, XKCDXKPasswd, } +// A slice of special characters which can be used for padding and separator +// characters var DefaultSpecialCharacters = []string{ "!", "@", "$", "%", "^", "&", "*", "-", "+", "=", ":", "|", "~", "?", "/", ".", ";", } +// A slice of available options for padding var PaddingType = []string{Adaptive, Fixed, None} +// A slice of available options for case transformation var TransformType = []string{ Alternate, AlternateLettercase, Capitalise, CapitaliseInvert, Invert, Lower, LowerVowelUpperConsonant, None, Random, Sentence, Upper, } +// A slice of available options for padding and separator characters var PaddingCharacterAndSeparatorCharacter = append([]string{Random}, DefaultSpecialCharacters...) +// A slice of available word lists var WordLists = []string{ All, DoctorWho, EN, ENSmall, GameOfThrones, HarryPotter, MiddleEarth, StarTrek, StarWars, } type Config struct { - CaseTransform string `key:"case_transform" json:"case_transform,omitempty"` - NumPasswords int `key:"num_passwords" json:"num_passwords,omitempty"` - NumWords int `key:"num_words" json:"num_words,omitempty"` - PaddingCharactersAfter int `key:"padding_characters_after" json:"padding_characters_after,omitempty"` - PaddingCharactersBefore int `key:"padding_characters_before" json:"padding_characters_before,omitempty"` - PaddingCharacter string `key:"padding_character" json:"padding_character,omitempty"` - PaddingDigitsAfter int `key:"padding_digits_after" json:"padding_digits_after,omitempty"` - PaddingDigitsBefore int `key:"padding_digits_before" json:"padding_digits_before,omitempty"` - PaddingType string `key:"padding_type" json:"padding_type,omitempty"` - PadToLength int `key:"pad_to_length" json:"pad_to_length,omitempty"` - Preset string `key:"preset" json:"preset,omitempty"` - SeparatorAlphabet []string `key:"separator_alphabet" json:"separator_alphabet,omitempty"` - SeparatorCharacter string `key:"separator_character" json:"separator_character,omitempty"` - SymbolAlphabet []string `key:"symbol_alphabet" json:"symbol_alphabet,omitempty"` - WordLengthMax int `key:"word_length_max" json:"word_length_max,omitempty"` - WordLengthMin int `key:"word_length_min" json:"word_length_min,omitempty"` - WordList string `key:"word_list" json:"word_list,omitempty"` + // The type of case transformation to apply to the words + CaseTransform string `key:"case_transform" json:"case_transform,omitempty"` + // The number of passwords to generate + NumPasswords int `key:"num_passwords" json:"num_passwords,omitempty"` + // The number of words to use in the password + NumWords int `key:"num_words" json:"num_words,omitempty"` + // The number of padding characters to add after the password + PaddingCharactersAfter int `key:"padding_characters_after" json:"padding_characters_after,omitempty"` + // The number of padding characters to add before the password + PaddingCharactersBefore int `key:"padding_characters_before" json:"padding_characters_before,omitempty"` + // The character to use for padding + PaddingCharacter string `key:"padding_character" json:"padding_character,omitempty"` + // Te number of padding digits to add after the password + PaddingDigitsAfter int `key:"padding_digits_after" json:"padding_digits_after,omitempty"` + // The number of padding digits to add before the password + PaddingDigitsBefore int `key:"padding_digits_before" json:"padding_digits_before,omitempty"` + // The type of padding to apply to the password + PaddingType string `key:"padding_type" json:"padding_type,omitempty"` + // The length to pad the password to + PadToLength int `key:"pad_to_length" json:"pad_to_length,omitempty"` + // The preset to use for generating the password + Preset string `key:"preset" json:"preset,omitempty"` + // The alphabet to use for the separator character when using a random character + SeparatorAlphabet []string `key:"separator_alphabet" json:"separator_alphabet,omitempty"` + // The character to use to separate the words + SeparatorCharacter string `key:"separator_character" json:"separator_character,omitempty"` + // The alphabet to use for the symbol padding character when random + SymbolAlphabet []string `key:"symbol_alphabet" json:"symbol_alphabet,omitempty"` + // The maximum length of a word to use in the password + WordLengthMax int `key:"word_length_max" json:"word_length_max,omitempty"` + // The minimum length of a word to use in the password + WordLengthMin int `key:"word_length_min" json:"word_length_min,omitempty"` + // The word list to use for generating the password + WordList string `key:"word_list" json:"word_list,omitempty"` } var WordListDescriptionMap = map[string]string{ diff --git a/go.mod b/go.mod index 8051118..7480751 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module github.com/eljamo/libpass/v3 +module github.com/eljamo/libpass/v4 -go 1.21.4 +go 1.21.5 require golang.org/x/text v0.14.0 diff --git a/internal/stringcheck/string_check.go b/internal/stringcheck/string_check.go index 12de9c7..6d428c9 100644 --- a/internal/stringcheck/string_check.go +++ b/internal/stringcheck/string_check.go @@ -9,3 +9,13 @@ func HasElementWithLengthGreaterThanOne(s []string) bool { return false } + +func IsElementInSlice(s []string, e string) bool { + for _, a := range s { + if a == e { + return true + } + } + + return false +} diff --git a/service/padding.go b/service/padding.go index 7ac3ae6..0cf133d 100644 --- a/service/padding.go +++ b/service/padding.go @@ -6,19 +6,28 @@ import ( "strings" "unicode/utf8" - "github.com/eljamo/libpass/v3/config" - "github.com/eljamo/libpass/v3/internal/stringcheck" + "github.com/eljamo/libpass/v4/config" + "github.com/eljamo/libpass/v4/internal/stringcheck" ) +// Defines the interface for a service that provides functionality to pad a +// slice of strings. type PaddingService interface { + // Takes a slice of strings and applies extra characters or digits + // and joins the slice, or returns an error Pad(slice []string) (string, error) } +// Implements the PaddingService interface. It provides methods to add padding +// to strings based on predefined configuration settings. type DefaultPaddingService struct { cfg *config.Config rngSvc RNGService } +// Creates a new instance of DefaultPaddingService with the provided +// configuration and RNGService. It returns an error if the provided +// configuration is invalid. func NewPaddingService(cfg *config.Config, rngSvc RNGService) (*DefaultPaddingService, error) { svc := &DefaultPaddingService{cfg, rngSvc} @@ -29,13 +38,18 @@ func NewPaddingService(cfg *config.Config, rngSvc RNGService) (*DefaultPaddingSe return svc, nil } +// Takes a slice of strings and applies padding based on the service's +// configuration. It returns the padded string or an error if padding cannot +// be applied. func (s *DefaultPaddingService) Pad(slice []string) (string, error) { pwd, err := s.digits(slice) if err != nil { return "", err } + // Remove any separator characters which remain on the edges of the slice pwe := s.removeEdgeSeparatorCharacter(pwd) + // Remove any whitespace characters which remain on the edges of the slice pwt := strings.TrimSpace(strings.Join(pwe, "")) pws, err := s.symbols(pwt) @@ -46,6 +60,9 @@ func (s *DefaultPaddingService) Pad(slice []string) (string, error) { return pws, nil } +// Adds random digits before and after the given slice based on the +// configuration. It returns a slice with the added digits or an error if the +// digits cannot be generated. func (s *DefaultPaddingService) digits(slice []string) ([]string, error) { before, after := s.cfg.PaddingDigitsBefore, s.cfg.PaddingDigitsAfter paddedSlice := make([]string, 0, before+len(slice)+after) @@ -68,10 +85,12 @@ func (s *DefaultPaddingService) digits(slice []string) ([]string, error) { return paddedSlice, nil } -func (s *DefaultPaddingService) generateRandomDigits(count int) ([]string, error) { - digits := make([]string, 0, count) - for i := 0; i < count; i++ { - num, err := s.rngSvc.GenerateWithMax(maxDigit) +// Generates a specified number of random digits. It returns a slice of the +// generated digits as strings or an error if the generation fails. +func (s *DefaultPaddingService) generateRandomDigits(num int) ([]string, error) { + digits := make([]string, 0, num) + for i := 0; i < num; i++ { + num, err := s.rngSvc.GenerateDigit() if err != nil { return nil, err } @@ -82,22 +101,55 @@ func (s *DefaultPaddingService) generateRandomDigits(count int) ([]string, error return digits, nil } +// Removes separator characters from the edges of the given slice. It handles +// both specified and random separator characters based on the configuration. func (s *DefaultPaddingService) removeEdgeSeparatorCharacter(slice []string) []string { if len(slice) == 0 { return slice } + sc := s.cfg.SeparatorCharacter + if sc == config.Random { + return s.removeRandomEdgeSeparatorCharacter(slice) + } + start, end := 0, len(slice) - if slice[start] == s.cfg.SeparatorCharacter { + if slice[start] == sc { start++ } - if end > start && slice[end-1] == s.cfg.SeparatorCharacter { + if end > start && slice[end-1] == sc { end-- } return slice[start:end] } +// Specifically handles the removal of random edge separator characters. It is +// used when the separator character is set to random in the configuration. +func (s *DefaultPaddingService) removeRandomEdgeSeparatorCharacter(slice []string) []string { + if len(slice) == 0 { + return slice + } + + sa := s.cfg.SeparatorAlphabet + if len(sa) == 0 { + return slice + } + + start, end := 0, len(slice) + if stringcheck.IsElementInSlice(sa, slice[start]) { + start++ + } + if end > start && stringcheck.IsElementInSlice(sa, slice[end-1]) { + end-- + } + + return slice[start:end] +} + +// Applies symbol-based padding to the provided string as per the service +// configuration. It handles different padding types (fixed, adaptive) and +// returns the padded string or an error. func (s *DefaultPaddingService) symbols(pw string) (string, error) { char, err := s.getPaddingCharacter() if err != nil { @@ -108,7 +160,7 @@ func (s *DefaultPaddingService) symbols(pw string) (string, error) { case config.Fixed: return s.fixed(pw, char) case config.Adaptive: - return s.adaptive(pw, char) + return s.adaptive(pw, char), nil case config.None: return pw, nil } @@ -116,6 +168,8 @@ func (s *DefaultPaddingService) symbols(pw string) (string, error) { return pw, nil } +// Retrieves the character to be used for padding. It selects a random character +// from the symbol alphabet if the padding character is set to random. func (s *DefaultPaddingService) getPaddingCharacter() (string, error) { if s.cfg.PaddingCharacter == config.Random { num, err := s.rngSvc.GenerateWithMax(len(s.cfg.SymbolAlphabet)) @@ -128,6 +182,8 @@ func (s *DefaultPaddingService) getPaddingCharacter() (string, error) { return s.cfg.PaddingCharacter, nil } +// Applies a fixed number of padding characters before and after the input +// string. It returns the resulting string with the specified fixed padding. func (s *DefaultPaddingService) fixed(pw string, char string) (string, error) { paddingBefore := strings.Repeat(char, s.cfg.PaddingCharactersBefore) paddingAfter := strings.Repeat(char, s.cfg.PaddingCharactersAfter) @@ -135,19 +191,24 @@ func (s *DefaultPaddingService) fixed(pw string, char string) (string, error) { return paddingBefore + pw + paddingAfter, nil } -func (s *DefaultPaddingService) adaptive(pw string, char string) (string, error) { +// Applies padding to the input string to meet a specified total length. +// The padding is added at the end of the string and uses the provided +// padding character. +func (s *DefaultPaddingService) adaptive(pw string, char string) string { padLen := s.cfg.PadToLength pwLen := utf8.RuneCountInString(pw) if padLen <= pwLen { - return pw, nil + return pw } diff := padLen - pwLen padding := strings.Repeat(char, diff) - return pw + padding, nil + return pw + padding } +// Checks the service's configuration for any invalid values. It ensures the +// integrity of the padding settings before processing the padding operations. func (s *DefaultPaddingService) validate() error { if s.cfg.PaddingType != config.None && s.cfg.PaddingCharacter != config.Random && len(s.cfg.PaddingCharacter) > 1 { return errors.New("padding_character must be a single character if specified") diff --git a/service/padding_test.go b/service/padding_test.go index c38dd4b..8e33959 100644 --- a/service/padding_test.go +++ b/service/padding_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/config" ) func TestNewPaddingService(t *testing.T) { @@ -19,8 +19,11 @@ func TestNewPaddingService(t *testing.T) { wantErr bool }{ { - name: "Valid configuration", - cfg: &config.Config{PaddingDigitsBefore: 2, PaddingDigitsAfter: 2, PaddingCharacter: "*", SymbolAlphabet: []string{"!", "@", "#", "$", "%"}, PaddingType: config.Fixed}, + name: "Valid configuration", + cfg: &config.Config{ + PaddingDigitsBefore: 2, PaddingDigitsAfter: 2, PaddingCharacter: "*", + SymbolAlphabet: []string{"!", "@", "#", "$", "%"}, PaddingType: config.Fixed, + }, wantErr: false, }, { @@ -267,6 +270,62 @@ func TestRemoveEdgeSeparatorCharacter(t *testing.T) { } } +func TestRemoveRandomEdgeSeparatorCharacter(t *testing.T) { + t.Parallel() + + cfg := &config.Config{SeparatorCharacter: "RANDOM", SeparatorAlphabet: []string{"!", "-", "="}} + + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "no separator at edges", + input: []string{"a", "b", "c"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "separator at start", + input: []string{"-", "a", "b", "c"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "separator at end", + input: []string{"a", "b", "c", "-"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "separator at both ends", + input: []string{"-", "a", "b", "c", "-"}, + expected: []string{"a", "b", "c"}, + }, + { + name: "empty input", + input: []string{}, + expected: []string{}, + }, + { + name: "input with only separators", + input: []string{"-", "-"}, + expected: []string{}, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + s := &DefaultPaddingService{cfg: cfg} + got := s.removeEdgeSeparatorCharacter(tt.input) + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("removeEdgeSeparatorCharacter() got = %v, expected %v", got, tt.expected) + } + }) + } +} + func TestSymbols(t *testing.T) { t.Parallel() @@ -470,10 +529,7 @@ func TestAdaptive(t *testing.T) { cfg.PadToLength = tt.padLen s := &DefaultPaddingService{cfg: cfg} - got, err := s.adaptive(tt.pw, tt.char) - if err != nil { - t.Errorf("adaptive() error = %v", err) - } + got := s.adaptive(tt.pw, tt.char) if got != tt.want { t.Errorf("adaptive() got = %v, want %v", got, tt.want) diff --git a/service/password_generator.go b/service/password_generator.go index ae14428..0cf6719 100644 --- a/service/password_generator.go +++ b/service/password_generator.go @@ -5,13 +5,22 @@ import ( "fmt" "sync" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/config" ) +// PasswordGeneratorService defines the interface for a service that generates +// passwords. It requires an implementation of the Generate method that returns +// a slice of strings representing generated passwords and an error if the +// generation process fails. type PasswordGeneratorService interface { + // Generate creates a list of passwords and returns the list or an error Generate() ([]string, error) } +// DefaultPasswordGeneratorService implements the PasswordGeneratorService +// interface providing a concrete implementation for password generation. It +// combines various services like transformers, separators, padders, and word +// list services to generate passwords based on provided configuration. type DefaultPasswordGeneratorService struct { cfg *config.Config transformerSvc TransformerService @@ -20,6 +29,11 @@ type DefaultPasswordGeneratorService struct { wordListSvc WordListService } +// NewCustomPasswordGeneratorService constructs a new instance of +// DefaultPasswordGeneratorService with the provided services and +// configuration. It validates the configuration and returns an +// error if the configuration is invalid (e.g., number of passwords +// is less than 1). func NewCustomPasswordGeneratorService( cfg *config.Config, transformerSvc TransformerService, @@ -41,6 +55,9 @@ func NewCustomPasswordGeneratorService( }, nil } +// NewPasswordGeneratorService constructs a DefaultPasswordGeneratorService with default +// implementations for its dependent services (transformer, separator, padding, and word list services). +// It initializes each service with the provided configuration and random number generator service. func NewPasswordGeneratorService( cfg *config.Config, ) (*DefaultPasswordGeneratorService, error) { @@ -66,46 +83,47 @@ func NewPasswordGeneratorService( return nil, err } - return NewCustomPasswordGeneratorService( - cfg, - ts, - ss, - ps, - wls, - ) + return NewCustomPasswordGeneratorService(cfg, ts, ss, ps, wls) } +// Generate creates a list of passwords using the services provided to the +// DefaultPasswordGeneratorService instance and returns the list of generated +// passwords or the first error if one or more is encountered. func (s *DefaultPasswordGeneratorService) Generate() ([]string, error) { np := s.cfg.NumPasswords var wg sync.WaitGroup - wg.Add(np) + wg.Add(np) // Set the number of goroutines to wait for. pws := make([]string, np) - errChan := make(chan error, np) + errChan := make(chan error, np) // Channel to capture errors from goroutines for i := 0; i < np; i++ { - go func(i int) { - defer wg.Done() + go func(i int) { // Launch a goroutine for each password generation. + defer wg.Done() // Signal completion of the goroutine at the end + // Get a list of words from the wordList service sl, err := s.wordListSvc.GetWords() if err != nil { - errChan <- err + errChan <- err // Send any errors to the error channel return } + // Transform the casing of words or letters using the transformer service slt, err := s.transformerSvc.Transform(sl) if err != nil { errChan <- err return } + // Separate the transformed list using the separator service using special characters sls, err := s.separatorSvc.Separate(slt) if err != nil { errChan <- err return } + // Pad the password with digits and special characters using the padding service pw, err := s.paddingSvc.Pad(sls) if err != nil { errChan <- err @@ -116,11 +134,12 @@ func (s *DefaultPasswordGeneratorService) Generate() ([]string, error) { }(i) } - wg.Wait() - close(errChan) + wg.Wait() // Wait for all goroutines to complete + close(errChan) // Close the error channel + // Check if there were any errors generated by the goroutines if len(errChan) > 0 { - return nil, fmt.Errorf("%w", <-errChan) + return nil, fmt.Errorf("%w", <-errChan) // Return the first error encountered } return pws, nil diff --git a/service/password_generator_test.go b/service/password_generator_test.go index 0f3ea86..0691656 100644 --- a/service/password_generator_test.go +++ b/service/password_generator_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/config" ) type mockTransformerService struct{} diff --git a/service/rng.go b/service/rng.go index 056fd08..160df8f 100644 --- a/service/rng.go +++ b/service/rng.go @@ -6,22 +6,35 @@ import ( "math/big" ) +const ( + maxInt = int(^uint(0) >> 1) // Maximum value for an int variable on the system + maxDigit = 10 // Maximum digit value, used in GenerateDigit. +) + +// RNGService defines an interface for random number generation. +// It provides methods for generating random integers and slices of integers. type RNGService interface { + // Generates a random integer up to the specified maximum value. GenerateWithMax(max int) (int, error) + // Generates a random integer Generate() (int, error) + // Generates a single digit (0-9). + GenerateDigit() (int, error) + // Generates a slice of random integers GenerateSlice(length int) ([]int, error) - GenerateSliceWithMax(length, max int) ([]int, error) + // Generates a slice of random integers, each up to the specified maximum value. + GenerateSliceWithMax(length int, max int) ([]int, error) } +// DefaultRNGService is a struct implementing the RNGService interface. type DefaultRNGService struct{} +// Creates a new instance of DefaultRNGService. func NewRNGService() *DefaultRNGService { return &DefaultRNGService{} } -var maxInt = int(^uint(0) >> 1) -var maxDigit = 10 - +// Generates a random integer up to the specified maximum value. func (s *DefaultRNGService) GenerateWithMax(max int) (int, error) { if max < 1 { return 0, errors.New("rng max cannot be less than 1") @@ -35,11 +48,18 @@ func (s *DefaultRNGService) GenerateWithMax(max int) (int, error) { return int(n.Int64()), nil } +// Generates a random integer with the maximum possible value for int. func (s *DefaultRNGService) Generate() (int, error) { return s.GenerateWithMax(maxInt) } -func (s *DefaultRNGService) GenerateSliceWithMax(length, max int) ([]int, error) { +// GenerateDigit generates a single digit (0-9). +func (s *DefaultRNGService) GenerateDigit() (int, error) { + return s.GenerateWithMax(maxDigit) +} + +// Generates a slice of random integers, each up to the specified maximum value. +func (s *DefaultRNGService) GenerateSliceWithMax(length int, max int) ([]int, error) { if length < 0 { return nil, errors.New("rng slice length cannot be less than 0") } @@ -64,6 +84,7 @@ func (s *DefaultRNGService) GenerateSliceWithMax(length, max int) ([]int, error) return slice, nil } +// Generates a slice of random integers with the maximum possible value for int. func (s *DefaultRNGService) GenerateSlice(length int) ([]int, error) { return s.GenerateSliceWithMax(length, maxInt) } diff --git a/service/rng_test.go b/service/rng_test.go index dc9f68f..4d5b9f7 100644 --- a/service/rng_test.go +++ b/service/rng_test.go @@ -14,6 +14,10 @@ func (s *MockRNGService) Generate() (int, error) { return 1, nil } +func (s *MockRNGService) GenerateDigit() (int, error) { + return 1, nil +} + func (s *MockRNGService) GenerateSlice(length int) ([]int, error) { return s.GenerateSliceWithMax(length, 2) } @@ -37,6 +41,10 @@ func (s *MockEvenRNGService) Generate() (int, error) { return 2, nil } +func (s *MockEvenRNGService) GenerateDigit() (int, error) { + return 2, nil +} + func (s *MockEvenRNGService) GenerateSlice(length int) ([]int, error) { return s.GenerateSliceWithMax(length, 2) } @@ -181,3 +189,19 @@ func TestGenerateSlice(t *testing.T) { } }) } + +func TestGenerateDigit(t *testing.T) { + t.Parallel() + + rngSvc := NewRNGService() + + for i := 0; i < 100; i++ { + digit, err := rngSvc.GenerateDigit() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if digit < 0 || digit > 9 { + t.Errorf("generated digit is out of range: got %d, want 0-9", digit) + } + } +} diff --git a/service/separator.go b/service/separator.go index e2abe0f..9c3be68 100644 --- a/service/separator.go +++ b/service/separator.go @@ -3,19 +3,28 @@ package service import ( "errors" - "github.com/eljamo/libpass/v3/config" - "github.com/eljamo/libpass/v3/internal/stringcheck" + "github.com/eljamo/libpass/v4/config" + "github.com/eljamo/libpass/v4/internal/stringcheck" ) +// Defines the interface for a service that can separate elements of a string +// slice. type SeparatorService interface { + // Separate takes a slice of strings and inserts a separator character + // between each element of the slice or returns an error if the slice + // cannot be separated Separate(slice []string) ([]string, error) } +// Implements the SeparatorService, providing functionality to separate string +// slices. type DefaultSeparatorService struct { cfg *config.Config rngSvc RNGService } +// Creates a new instance of DefaultSeparatorService. It validates the provided +// configuration and returns an error if the configuration is invalid. func NewSeparatorService(cfg *config.Config, rngSvc RNGService) (*DefaultSeparatorService, error) { svc := &DefaultSeparatorService{cfg, rngSvc} @@ -26,6 +35,10 @@ func NewSeparatorService(cfg *config.Config, rngSvc RNGService) (*DefaultSeparat return svc, nil } +// Separate takes a slice of strings and inserts a separator character between +// each element of the slice. The separator character is determined based on the +// configuration. It returns the modified slice or an error if the separator +// character cannot be determined. func (s *DefaultSeparatorService) Separate(slice []string) ([]string, error) { char, err := s.getSeparatorCharacter() if err != nil { @@ -41,6 +54,9 @@ func (s *DefaultSeparatorService) Separate(slice []string) ([]string, error) { return separatedSlice, nil } +// Returns the separator character based on the service configuration. It either +// returns a predefined character or a random character from a specified +// alphabet. Returns an error if it fails to return a random character. func (s *DefaultSeparatorService) getSeparatorCharacter() (string, error) { if s.cfg.SeparatorCharacter == config.Random { sa := s.cfg.SeparatorAlphabet @@ -56,6 +72,10 @@ func (s *DefaultSeparatorService) getSeparatorCharacter() (string, error) { return s.cfg.SeparatorCharacter, nil } +// Checks the configuration of the DefaultSeparatorService for correctness. +// It ensures that the separator character is either a single character or a +// valid random character from the alphabet. Returns an error if the +// configuration is invalid. func (s *DefaultSeparatorService) validate() error { if s.cfg.SeparatorCharacter != config.Random && len(s.cfg.SeparatorCharacter) > 1 { return errors.New("separator_character must be a single character if specified") diff --git a/service/separator_test.go b/service/separator_test.go index 124fbd8..f4c85ff 100644 --- a/service/separator_test.go +++ b/service/separator_test.go @@ -4,7 +4,7 @@ import ( "slices" "testing" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/config" ) func TestNewSeparatorService(t *testing.T) { diff --git a/service/transformer.go b/service/transformer.go index a8400b4..f3dc610 100644 --- a/service/transformer.go +++ b/service/transformer.go @@ -6,20 +6,29 @@ import ( "strings" "unicode" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/config" "golang.org/x/text/cases" "golang.org/x/text/language" ) +var caser = cases.Title(language.English) + +// Defines an interface for transforming a slice of strings type TransformerService interface { + // Transform takes a slice of strings and transforms each element or returns + // an error if the transformation fails. Transform(slice []string) ([]string, error) } +// Implementats the TransformerService, providing functionality to transform +// string slices based on a predefined configuration. type DefaultTransformerService struct { cfg *config.Config rngSvc RNGService } +// Creates a new valid instance of DefaultTransformerService with the given +// configuration and RNG service func NewTransformerService(cfg *config.Config, rngSvc RNGService) (*DefaultTransformerService, error) { svc := &DefaultTransformerService{cfg, rngSvc} @@ -30,6 +39,21 @@ func NewTransformerService(cfg *config.Config, rngSvc RNGService) (*DefaultTrans return svc, nil } +// Transform takes a slice of strings and transforms each element +// according to the configured transformation rule. +// Returns the transformed slice or an error if the transformation fails. +// +// Transform Types: +// - Alternate +// - AlternateLettercase +// - Capitalise +// - CapitaliseInvert +// - Invert +// - Lower +// - LowerVowelUpperConsonant +// - Random +// - Sentence +// - Upper func (s *DefaultTransformerService) Transform(slice []string) ([]string, error) { switch s.cfg.CaseTransform { case config.Alternate: @@ -60,8 +84,9 @@ func (s *DefaultTransformerService) Transform(slice []string) ([]string, error) return slice, nil } -var caser = cases.Title(language.English) - +// alternate applies alternating casing to each element of the slice. +// +// Example Output: string[]{"hello", "WORLD"} func (s *DefaultTransformerService) alternate(slice []string) []string { for i, w := range slice { if i%2 == 0 { @@ -74,6 +99,13 @@ func (s *DefaultTransformerService) alternate(slice []string) []string { return slice } +// alternateLettercase takes a slice of strings and alternates the casing of +// each letter within each string. Starting with lowercase, it switches +// between lowercase and uppercase for each subsequent letter. +// The function returns a new slice of strings with the applied transformations +// or an error if an issue occurs during string building. +// +// Example Output: string[]{"hElLo", "WoRlD"}, nil func alternateLettercase(slice []string) ([]string, error) { var result []string for _, str := range slice { @@ -100,6 +132,9 @@ func alternateLettercase(slice []string) ([]string, error) { return result, nil } +// Capitialises each element in the slice +// +// Example Output: string[]{"Hello", "World"} func (s *DefaultTransformerService) capitalise(slice []string) []string { for i, w := range slice { slice[i] = caser.String(w) @@ -108,6 +143,9 @@ func (s *DefaultTransformerService) capitalise(slice []string) []string { return slice } +// Inverts the casing of a capitialised string in the slice +// +// Exmaple output: string[]{"hELLO", "wORLD"}, nil func (s *DefaultTransformerService) capitaliseInvert(slice []string) ([]string, error) { for i, w := range slice { var sb strings.Builder @@ -141,6 +179,14 @@ func isVowel(r rune) bool { return strings.ContainsRune("aeiouAEIOU", r) } +// lowerVowelUpperConsonant processes a slice of strings, transforming each string +// by applying lowercase to vowels and uppercase to consonants. +// It iterates through each rune in a string, checks if it is a vowel using +// the isVowel function, and accordingly changes its case. +// The function returns the transformed slice of strings or an error if any +// occurs during the string building process. +// +// Example Output: string[]{"hEllO", "wOrld"}, nil func lowerVowelUpperConsonant(slice []string) ([]string, error) { var result []string for _, str := range slice { @@ -180,6 +226,9 @@ func (s *DefaultTransformerService) random(slice []string) ([]string, error) { return slice, nil } +// sentence applies sentence casing across each element of the slice +// +// Example Output: string[]{"Hello", "world"} func (s *DefaultTransformerService) sentence(slice []string) []string { for i, w := range slice { if i == 0 { diff --git a/service/transformer_test.go b/service/transformer_test.go index 905bde3..cb1dcb0 100644 --- a/service/transformer_test.go +++ b/service/transformer_test.go @@ -5,7 +5,7 @@ import ( "reflect" "testing" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/config" ) func TestNewTransformerService(t *testing.T) { @@ -137,6 +137,20 @@ func TestDefaultTransformerService_Transform(t *testing.T) { input: []string{"hello", "world"}, expected: []string{"HELLO", "WORLD"}, }, + { + name: "Empty slice", + cfg: &config.Config{CaseTransform: config.Random}, + rngSvc: rngs, + input: []string{}, + expected: []string{}, + }, + { + name: "Special characters slice", + cfg: &config.Config{CaseTransform: config.Random}, + rngSvc: rngs, + input: []string{"-", "&"}, + expected: []string{"-", "&"}, + }, } for _, tt := range tests { diff --git a/service/word_list.go b/service/word_list.go index 8d9f02c..a306d32 100644 --- a/service/word_list.go +++ b/service/word_list.go @@ -4,20 +4,28 @@ import ( "errors" "fmt" - "github.com/eljamo/libpass/v3/asset" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/asset" + "github.com/eljamo/libpass/v4/config" ) +// Defines the interface for a service that extracts words from word lists type WordListService interface { + // GetWords returns a slice of words extracted from a word list or an error + // if the words cannot be extracted. GetWords() ([]string, error) } +// Implements the interface WordListService, providing functionality to extract +// words from a word list. type DefaultWordListService struct { cfg *config.Config rngSvc RNGService wordList []string } +// Creates a new instance of DefaultWordListService. It requires configuration +// and a random number generation service. It returns an error if the +// configuration is invalid. func NewWordListService(cfg *config.Config, rngSvc RNGService) (*DefaultWordListService, error) { if cfg.NumWords < 2 { return nil, errors.New("num_words must be greater than or equal to 2") @@ -35,30 +43,27 @@ func NewWordListService(cfg *config.Config, rngSvc RNGService) (*DefaultWordList }, nil } +// Creates a word list based on provided criteria. It returns an error if the +// criteria are invalid or the word list cannot be created. func getWordList(wordList string, wordMinLength int, wordMaxLength int) ([]string, error) { if wordMaxLength < wordMinLength { return nil, fmt.Errorf("word_length_max (%d) must be greater than or equal to word_length_min (%d)", wordMaxLength, wordMinLength) } - wl, err := asset.GetWordList(wordList) + wl, err := asset.GetFilteredWordList(wordList, wordMinLength, wordMaxLength) if err != nil { return nil, err } - var fw []string - for _, word := range wl { - if len(word) >= wordMinLength && len(word) <= wordMaxLength { - fw = append(fw, string(word)) - } - } - - if len(fw) == 0 { + if len(wl) == 0 { return nil, fmt.Errorf("no words found in word list %s with a word_length_min of %d and word_length_max of %d", wordList, wordMinLength, wordMaxLength) } - return fw, nil + return wl, nil } +// Creates a slice of words randomly extracted from a word list. It returns +// an error if the slice cannot be created. func (s *DefaultWordListService) GetWords() ([]string, error) { wll := len(s.wordList) wn, err := s.rngSvc.GenerateSliceWithMax(s.cfg.NumWords, wll) diff --git a/service/word_list_test.go b/service/word_list_test.go index 39c28b3..5283697 100644 --- a/service/word_list_test.go +++ b/service/word_list_test.go @@ -3,7 +3,7 @@ package service import ( "testing" - "github.com/eljamo/libpass/v3/config" + "github.com/eljamo/libpass/v4/config" ) func TestNewWordListService(t *testing.T) { @@ -15,22 +15,22 @@ func TestNewWordListService(t *testing.T) { wantErr bool }{ { - name: "ValidConfig", + name: "Valid Config", cfg: &config.Config{NumWords: 5, WordList: "EN_SMALL", WordLengthMin: 2, WordLengthMax: 10}, wantErr: false, }, { - name: "InvalidConfigWordLength", + name: "Invalid Config - WordLength", cfg: &config.Config{NumWords: 5, WordList: "EN_SMALL", WordLengthMin: 15, WordLengthMax: 20}, wantErr: true, }, { - name: "InvalidConfigWordLengthMax", + name: "Invalid Config - WordLengthMax", cfg: &config.Config{NumWords: 5, WordList: "EN_SMALL", WordLengthMin: 10, WordLengthMax: 2}, wantErr: true, }, { - name: "InvalidConfigNumWords", + name: "Invalid Config - NumWords", cfg: &config.Config{NumWords: 1}, wantErr: true, },