Refactor for better modularity (share more code among edit functions, rename "offline" package to "backend"

This commit is contained in:
2024-04-15 18:16:05 -04:00
parent 53e0799a4a
commit 257a698b88
19 changed files with 834 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
package backend
import (
"os"
)
// global variables used across multiple files
var (
home, _ = os.UserHomeDir()
)
// global constants used across multiple files
const (
AnsiError = "\033[38;5;9m"
AnsiReset = "\033[0m"
)
+14
View File
@@ -0,0 +1,14 @@
//go:build !windows
package backend
// EntryRoot path to libmutton entry directory
var EntryRoot = home + "/.local/share/libmutton"
var ConfigDir = home + "/.config/libmutton"
var ConfigPath = ConfigDir + "/libmutton.ini"
// PathSeparator defines the character used to separate directories in a path (platform-specific)
const (
PathSeparator = "/"
Windows = false // TODO temporary, remove after native sync is implemented
)
+14
View File
@@ -0,0 +1,14 @@
//go:build windows
package backend
// EntryRoot path to libmutton entry directory
var EntryRoot = home + "\\AppData\\Local\\libmutton\\entries"
var ConfigDir = home + "\\AppData\\Local\\libmutton"
var ConfigPath = ConfigDir + "\\libmutton.ini"
// PathSeparator defines the character used to separate directories in a path (platform-specific)
const (
PathSeparator = "\\"
Windows = true // TODO temporary, remove after native sync is implemented
)
+23
View File
@@ -0,0 +1,23 @@
package backend
import (
"fmt"
"os"
)
// AddFolder creates a new directory at targetLocation
func AddFolder(targetLocation string) {
// create the directory specified by targetLocation
err := os.Mkdir(targetLocation, 0700)
if err != nil {
if os.IsExist(err) {
fmt.Println(AnsiError + "Directory already exists" + AnsiReset)
os.Exit(1)
} else {
fmt.Println(AnsiError + "Failed to create directory: " + err.Error() + AnsiReset)
os.Exit(1)
}
}
// TODO If in online mode, create the directory on the server
os.Exit(0)
}
+46
View File
@@ -0,0 +1,46 @@
package backend
import (
"fmt"
"gopkg.in/ini.v1"
"os"
)
// ReadConfig reads the libmutton.ini file and returns a map of the requested values
// requires readMap: a map of section names to key names (indicates requested values)
// returns configMap: a map of key names to values (sections are irrelevant)
func ReadConfig(readKeys []string) []string {
cfg, err := ini.Load(ConfigPath)
if err != nil {
fmt.Println(AnsiError + "Failed to load libmutton.ini" + AnsiReset)
os.Exit(1)
}
var config []string
for _, key := range readKeys {
keyConfig := cfg.Section("LIBMUTTON").Key(key).String()
// ensure specified key has a value
if keyConfig == "" {
fmt.Println(AnsiError + "Failed to find value for key \"" + key + "\" in section \"[LIBMUTTON]\" in libmutton.ini" + AnsiReset)
os.Exit(1)
}
config = append(config, keyConfig)
}
return config
}
// libmuttn.ini layout
// [LIBMUTTON]
// gpgID = <gpg key id>
// textEditor = <editor command>
// onlineMode = <true/false>
// sshError = <true/false>
// netPinEnabled = <true/false>
// remoteUser = <ssh user>
// remoteIP = <ssh ip>
// remotePort = <ssh port>
// identityFile = <path to private key>
+88
View File
@@ -0,0 +1,88 @@
package backend
import (
"bufio"
"fmt"
steamtotp "github.com/fortis/go-steam-totp"
"github.com/pquerna/otp/totp"
"os"
"strings"
"time"
)
// CopyArgument copies a field from an entry to the clipboard
func CopyArgument(targetLocation string, field int, executableName string) {
if isFile, _ := TargetIsFile(targetLocation, true, 2); isFile {
decryptedEntry := DecryptGPG(targetLocation)
var copySubject string // will store data to be copied
// ensure field exists in entry
if len(decryptedEntry) > field {
// ensure field is not empty
if decryptedEntry[field] == "" {
fmt.Println(AnsiError + "Field is empty" + AnsiReset)
os.Exit(1)
}
if field != 5 { // TODO Update field after removed from notes (breaking sshyp entry compatibility)
copySubject = decryptedEntry[field]
} else { // TOTP mode
var secret string // stores secret for TOTP generation
var forSteam bool // indicates whether to generate TOTP in Steam format
if strings.HasPrefix(decryptedEntry[5], "steam@") {
secret = decryptedEntry[5][6:]
forSteam = true
} else {
secret = decryptedEntry[5]
}
for { // keep field copied to clipboard, refresh on 30-second intervals
currentTime := time.Now()
copyField(GenTOTP(secret, currentTime, forSteam), "")
// sleep until next 30-second interval
time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second)
}
}
} else {
fmt.Println(AnsiError + "Field does not exist in entry" + AnsiReset)
os.Exit(1)
}
// copy field to clipboard, launch clipboard clearing process
copyField(copySubject, executableName)
}
}
// ClipClearArgument is called to clear the clipboard after 30 seconds if the contents have not been modified
func ClipClearArgument() {
// read previous clipboard contents from stdin
clipScanner := bufio.NewScanner(os.Stdin)
if clipScanner.Scan() {
oldContents := clipScanner.Text()
clipClear(oldContents)
} else {
os.Exit(0)
}
}
// GenTOTP generates a TOTP token from a secret (supports standard and Steam TOTP)
func GenTOTP(secret string, time time.Time, forSteam bool) string {
var totpToken string
var err error
if forSteam {
totpToken, err = steamtotp.GenerateAuthCode(secret, time)
} else {
totpToken, err = totp.GenerateCode(secret, time)
}
if err != nil {
fmt.Println(AnsiError + "Error generating TOTP code" + AnsiReset)
os.Exit(1)
}
return totpToken
}
+55
View File
@@ -0,0 +1,55 @@
//go:build darwin
package backend
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// TODO MacOS support is entirely untested - I would appreciate feedback on this implementation
// copyField copies a field from an entry to the clipboard
func copyField(copySubject string, executableName string) {
cmd := exec.Command("pbcopy")
writeToStdin(cmd, copySubject)
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError + "Failed to copy to clipboard: " + err.Error() + AnsiReset)
os.Exit(1)
}
// launch clipboard clearing process if executableName is provided
if executableName != "" {
cmd = exec.Command(executableName, "clipclear")
writeToStdin(cmd, copySubject)
err = cmd.Start()
if err != nil {
fmt.Println(AnsiError + "Failed to launch automated clipboard clearing process - does this libmutton implementation support the \"clipclear\" argument?" + AnsiReset)
os.Exit(1)
}
os.Exit(0) // only exit if clipboard clearing process is launched, otherwise assume continuous clipboard refresh
}
}
// clipClear is called in a separate process to clear the clipboard after 30 seconds
func clipClear(oldContents string) {
time.Sleep(30 * time.Second)
cmd := exec.Command("pbpaste")
newContents, _ := cmd.Output()
if oldContents == strings.TrimRight(string(newContents), "\r\n") {
cmd = exec.Command("pbcopy")
writeToStdin(cmd, "")
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError + "Failed to clear clipboard: " + err.Error() + AnsiReset)
os.Exit(1)
}
}
os.Exit(0)
}
+53
View File
@@ -0,0 +1,53 @@
//go:build termux
package backend
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// copyField copies a field from an entry to the clipboard
func copyField(copySubject string, executableName string) {
cmd := exec.Command("termux-clipboard-set")
writeToStdin(cmd, copySubject)
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError + "Failed to copy to clipboard: " + err.Error() + AnsiReset)
os.Exit(1)
}
// launch clipboard clearing process if executableName is provided
if executableName != "" {
cmd = exec.Command(executableName, "clipclear")
writeToStdin(cmd, copySubject)
err = cmd.Start()
if err != nil {
fmt.Println(AnsiError + "Failed to launch automated clipboard clearing process - does this libmutton implementation support the \"clipclear\" argument?" + AnsiReset)
os.Exit(1)
}
os.Exit(0) // only exit if clipboard clearing process is launched, otherwise assume continuous clipboard refresh
}
}
// clipClear is called in a separate process to clear the clipboard after 30 seconds
func clipClear(oldContents string) {
time.Sleep(30 * time.Second)
cmd := exec.Command("termux-clipboard-get")
newContents, _ := cmd.Output()
if oldContents == strings.TrimRight(string(newContents), "\r\n") {
cmd = exec.Command("termux-clipboard-set")
writeToStdin(cmd, "")
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError + "Failed to clear clipboard: " + err.Error() + AnsiReset)
os.Exit(1)
}
}
os.Exit(0)
}
+77
View File
@@ -0,0 +1,77 @@
//go:build !windows && !darwin && !termux
package backend
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// copyField copies a field from an entry to the clipboard
func copyField(copySubject string, executableName string) {
var envSet bool // track whether environment variables are set
var cmd *exec.Cmd
// determine whether to use wl-copy (Wayland) or xclip (X11)
if _, envSet = os.LookupEnv("WAYLAND_DISPLAY"); envSet {
cmd = exec.Command("wl-copy")
} else if _, envSet = os.LookupEnv("DISPLAY"); envSet {
cmd = exec.Command("xclip", "-sel", "c")
} else {
fmt.Println(AnsiError + "Clipboard platform could not be determined - note that the clipboard does not function in a raw TTY" + AnsiReset)
os.Exit(1)
}
writeToStdin(cmd, copySubject)
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError + "Failed to copy to clipboard: " + err.Error() + AnsiReset)
os.Exit(1)
}
// launch clipboard clearing process if executableName is provided
if executableName != "" {
cmd = exec.Command(executableName, "clipclear")
writeToStdin(cmd, copySubject)
err = cmd.Start()
if err != nil {
fmt.Println(AnsiError + "Failed to launch automated clipboard clearing process - does this libmutton implementation support the \"clipclear\" argument?" + AnsiReset)
os.Exit(1)
}
os.Exit(0) // only exit if clipboard clearing process is launched, otherwise assume continuous clipboard refresh
}
}
// clipClear is called in a separate process to clear the clipboard after 30 seconds
func clipClear(oldContents string) {
time.Sleep(30 * time.Second)
var envSet bool // track whether environment variables are set
var platform bool // track clipboard platform, false for Wayland, true for X11
var cmd *exec.Cmd
// determine whether to use wl-copy (Wayland) or xclip (X11)
if _, envSet = os.LookupEnv("WAYLAND_DISPLAY"); envSet {
cmd = exec.Command("wl-paste")
} else if _, envSet = os.LookupEnv("DISPLAY"); envSet {
cmd = exec.Command("xclip", "-o", "-sel", "c")
platform = true
}
newContents, _ := cmd.Output()
if oldContents == strings.TrimRight(string(newContents), "\r\n") {
switch platform {
case false:
cmd = exec.Command("wl-copy", "-c")
case true:
cmd = exec.Command("xclip", "-i", "/dev/null", "-sel", "c")
}
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError + "Failed to clear clipboard: " + err.Error() + AnsiReset)
os.Exit(1)
}
}
os.Exit(0)
}
+51
View File
@@ -0,0 +1,51 @@
//go:build windows
package backend
import (
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// copyField copies a field from an entry to the clipboard
func copyField(copySubject string, executableName string) {
cmd := exec.Command("powershell.exe", "-c", fmt.Sprintf("echo '%s' | Set-Clipboard", strings.ReplaceAll(copySubject, "'", "''")))
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError + "Failed to copy to clipboard: " + err.Error() + AnsiReset)
os.Exit(1)
}
// launch clipboard clearing process if executableName is provided
if executableName != "" {
cmd = exec.Command(executableName, "clipclear")
writeToStdin(cmd, copySubject)
err = cmd.Start()
if err != nil {
fmt.Println(AnsiError + "Failed to launch automated clipboard clearing process - does this libmutton implementation support the \"clipclear\" argument?" + AnsiReset)
os.Exit(1)
}
os.Exit(0) // only exit if clipboard clearing process is launched, otherwise assume continuous clipboard refresh
}
}
// clipClear is called in a separate process to clear the clipboard after 30 seconds
func clipClear(oldContents string) {
time.Sleep(30 * time.Second)
cmd := exec.Command("powershell.exe", "-c", "Get-Clipboard")
newContents, _ := cmd.Output()
if oldContents == strings.TrimRight(string(newContents), "\r\n") {
cmd = exec.Command("powershell.exe", "-c", "Set-Clipboard")
err := cmd.Run()
if err != nil {
fmt.Println(AnsiError + "Failed to clear clipboard: " + err.Error() + AnsiReset)
os.Exit(1)
}
}
os.Exit(0)
}
+49
View File
@@ -0,0 +1,49 @@
package backend
import (
"fmt"
"os"
)
// GetOldEntryData decrypts and returns old entry data (with all required lines present)
func GetOldEntryData(targetLocation string, field int) []string {
// ensure targetLocation exists
TargetIsFile(targetLocation, true, 2)
// read old entry data
unencryptedEntry := DecryptGPG(targetLocation)
// return the old entry data with all required lines present
if field > 0 {
return EnsureSliceLength(unencryptedEntry, field)
} else {
return unencryptedEntry
}
}
// Rename renames oldLocation to newLocation
func Rename(oldLocation string, newLocation string) {
// ensure newLocation does not exist
_, isAccessible := TargetIsFile(newLocation, false, 0)
if isAccessible {
fmt.Println(AnsiError + "\"" + newLocation + "\" already exists" + AnsiReset)
os.Exit(1)
}
// rename oldLocation to newLocation
err := os.Rename(oldLocation, newLocation)
if err != nil {
fmt.Println(AnsiError + "Failed to rename - does the target containing directory exists?" + AnsiReset)
}
// TODO If in online mode, check if oldLocation is a directory and rename it on the server
os.Exit(0)
}
// EnsureSliceLength ensures slice is long enough to contain the specified index
func EnsureSliceLength(slice []string, index int) []string {
for len(slice) <= index {
slice = append(slice, "")
}
return slice
}
+35
View File
@@ -0,0 +1,35 @@
package backend
import (
"fmt"
"os"
"os/exec"
"strings"
)
// TODO GPG support is a temporary feature - it will be replaced with a different encryption scheme in the future
// TODO These functions may continue to exist after that point, but consider them deprecated
// DecryptGPG decrypts a GPG-encrypted file and returns the contents as a slice of (trimmed) strings
func DecryptGPG(targetLocation string) []string {
cmd := exec.Command("gpg", "--pinentry-mode", "loopback", "-q", "-d", targetLocation)
output, err := cmd.Output()
if err != nil {
fmt.Println(AnsiError + "Failed to decrypt \"" + targetLocation + "\" - ensure it is a valid GPG-encrypted file and that you entered your passphrase correctly" + AnsiReset)
os.Exit(1)
}
outputSlice := strings.Split(string(output), "\n")
return outputSlice
}
// EncryptGPG encrypts a slice of strings using GPG and returns the encrypted data as a byte slice
func EncryptGPG(input []string) []byte {
cmd := exec.Command("gpg", "-q", "-r", ReadConfig([]string{"gpgID"})[0], "-e")
writeToStdin(cmd, strings.Join(input, "\n"))
encryptedBytes, err := cmd.Output()
if err != nil {
fmt.Println(AnsiError + "Failed to encrypt data - ensure that your GPG key is valid and that you have a valid GPG ID set in libmutton.ini" + AnsiReset)
os.Exit(1)
}
return encryptedBytes
}
+69
View File
@@ -0,0 +1,69 @@
package backend
import (
"os"
"os/exec"
"strconv"
"strings"
"time"
)
// TempInit ensures libmutton directories exist and writes the libmutton configuration file
func TempInit(configFileMap map[string]string) {
// create EntryRoot and ConfigDir
dirInit()
// remove existing config file
removeFile(ConfigPath)
if configFileMap["textEditor"] == "" {
configFileMap["textEditor"] = textEditorFallback()
}
// create and write config file
configFile, _ := os.OpenFile(ConfigPath, os.O_CREATE|os.O_WRONLY, 0600)
defer configFile.Close()
configFile.WriteString("[LIBMUTTON]\n")
for key, value := range configFileMap {
configFile.WriteString(key + " = " + value + "\n")
}
os.Exit(0)
}
// GpgUIDListGen generates a list of all GPG key IDs on the system and returns them as a slice of strings
func GpgUIDListGen() []string {
cmd := exec.Command("gpg", "-k", "--with-colons")
gpgOutputBytes, _ := cmd.Output()
gpgOutputLines := strings.Split(string(gpgOutputBytes), "\n")
var uidSlice []string
for _, line := range gpgOutputLines {
if strings.HasPrefix(line, "uid") {
uid := strings.Split(line, ":")[9]
uidSlice = append(uidSlice, uid)
}
}
return uidSlice
}
// GpgKeyGen generates a new GPG key and returns the key ID
func GpgKeyGen() string {
gpgGenTempFile := CreateTempFile()
defer os.Remove(gpgGenTempFile.Name())
// create and write gpg-gen file
unixTime := strconv.FormatInt(time.Now().Unix(), 10)
gpgGenTempFile.WriteString(strings.Join([]string{"Key-Type: eddsa", "Key-Curve: ed25519", "Key-Usage: sign", "Subkey-Type: ecdh", "Subkey-Curve: cv25519", "Subkey-Usage: encrypt", "Name-Real: libmutton-" + unixTime, "Name-Comment: gpg-libmutton", "Name-Email: github.com/rwinkhart/libmutton", "Expire-Date: 0"}, "\n"))
// close gpg-gen file
gpgGenTempFile.Close()
// generate GPG key based on gpg-gen file
cmd := exec.Command("gpg", "-q", "--batch", "--generate-key", gpgGenTempFile.Name())
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Run()
return "libmutton-" + unixTime + " (gpg-libmutton) <github.com/rwinkhart/libmutton>"
}
+37
View File
@@ -0,0 +1,37 @@
//go:build !windows
package backend
import (
"fmt"
"os"
)
const FallbackEditor = "vi" // vi is pre-installed on most UNIX systems
// dirInit creates the libmutton directories
func dirInit() {
// create EntryRoot
err := os.MkdirAll(EntryRoot, 0700)
if err != nil {
fmt.Println(AnsiError + "Failed to create \"" + EntryRoot + "\":" + err.Error() + AnsiReset)
os.Exit(1)
}
// create config directory
err = os.MkdirAll(ConfigDir, 0700)
if err != nil {
fmt.Println(AnsiError + "Failed to create \"" + ConfigDir + "\":" + err.Error() + AnsiReset)
os.Exit(1)
}
}
// textEditorFallback returns the value of the $EDITOR environment variable, or FallbackEditor if it is not set
func textEditorFallback() string {
// ensure textEditor is set
textEditor := os.Getenv("EDITOR")
if textEditor == "" {
textEditor = FallbackEditor
}
return textEditor
}
+25
View File
@@ -0,0 +1,25 @@
//go:build windows
package backend
import (
"fmt"
"os"
)
const FallbackEditor = "nvim" // since there is no pre-installed CLI editor on Windows, default to the most popular one
// dirInit creates the libmutton directories
func dirInit() {
// create EntryRoot (includes config directory on Windows)
err := os.MkdirAll(EntryRoot, 0700)
if err != nil {
fmt.Println(AnsiError + "Failed to create \"" + EntryRoot + "\":" + err.Error() + AnsiReset)
os.Exit(1)
}
}
// textEditorFallback returns FallbackEditor
func textEditorFallback() string {
return FallbackEditor
}
+17
View File
@@ -0,0 +1,17 @@
package backend
import (
"fmt"
"os"
)
func Shear(targetLocation string) {
// TODO If in online mode, remove from server and add to shear list
TargetIsFile(targetLocation, true, 0) // needed because os.RemoveAll does not return an error if target does not exist
err := os.RemoveAll(targetLocation)
if err != nil {
fmt.Println(AnsiError + "Failed to remove target: " + err.Error() + AnsiReset)
os.Exit(1)
}
os.Exit(0)
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package backend
// TargetLocationFormat returns the target location of an entry formatted for the current platform
func TargetLocationFormat(entryName string) string {
return EntryRoot + PathSeparator + entryName
}
+10
View File
@@ -0,0 +1,10 @@
//go:build windows
package backend
import "strings"
// TargetLocationFormat returns the target location of an entry formatted for the current platform
func TargetLocationFormat(entryName string) string {
return EntryRoot + PathSeparator + strings.ReplaceAll(entryName, "/", PathSeparator)
}
+147
View File
@@ -0,0 +1,147 @@
package backend
import (
"crypto/rand"
"fmt"
"io"
"math"
"math/big"
"os"
"os/exec"
"strings"
)
const extendedCharset = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
// TargetIsFile TargetStatusCheck checks if the targetLocation is a file, directory, or is inaccessible
// failCondition: 0 = fail on inaccessible, 1 = fail on inaccessible/file, 2 = fail on inaccessible/directory
// returns: isFile, isAccessible
func TargetIsFile(targetLocation string, errorOnFail bool, failCondition uint8) (bool, bool) {
targetInfo, err := os.Stat(targetLocation)
if err != nil {
if errorOnFail {
fmt.Println(AnsiError + "Failed to access \"" + targetLocation + "\" - ensure it exists and has the correct permissions" + AnsiReset)
os.Exit(1)
} else {
return false, false
}
}
if targetInfo.IsDir() {
if errorOnFail && failCondition == 2 {
fmt.Println(AnsiError + "\"" + targetLocation + "\" is a directory" + AnsiReset)
os.Exit(1)
}
return false, true
} else {
if errorOnFail && failCondition == 1 {
fmt.Println(AnsiError + "\"" + targetLocation + "\" is a file" + AnsiReset)
os.Exit(1)
}
return true, true
}
}
// WriteEntry writes entryData to an encrypted file at targetLocation
func WriteEntry(targetLocation string, entryData []string) {
encryptedBytes := EncryptGPG(entryData)
err := os.WriteFile(targetLocation, encryptedBytes, 0600)
if err != nil {
fmt.Println(AnsiError + "Failed to write to file: " + err.Error() + AnsiReset)
os.Exit(1)
}
}
// writeToStdin writes a string to a command's stdin
func writeToStdin(cmd *exec.Cmd, input string) {
stdin, _ := cmd.StdinPipe()
go func() {
defer stdin.Close()
io.WriteString(stdin, input)
}()
}
// CreateTempFile creates a temporary file and returns a pointer to it
func CreateTempFile() *os.File {
tempFile, err := os.CreateTemp("", "*.markdown")
if err != nil {
fmt.Println(AnsiError + "Failed to create temporary file: " + err.Error() + AnsiReset)
os.Exit(1)
}
return tempFile
}
// removeFile removes a file at targetLocation and does not error if the file does not exist
func removeFile(targetLocation string) {
// remove existing config file
err := os.Remove(targetLocation)
if err != nil {
// ignore error if file does not exist
if !os.IsNotExist(err) {
fmt.Println(AnsiError + "Failed to remove \"" + targetLocation + "\":" + err.Error() + AnsiReset)
}
}
}
// RemoveTrailingEmptyStrings removes empty strings from the end of a slice
func RemoveTrailingEmptyStrings(slice []string) []string {
for i := len(slice) - 1; i >= 0; i-- {
if slice[i] != "" {
return slice[:i+1]
}
}
return []string{}
}
// StringGen generates a random string of a specified length and complexity
// complexity: minimum percentage of special characters to be returned in the generated string (only impacts complex strings)
func StringGen(length int, complex bool, complexity float64) string {
var actualSpecialChars int // track the number of special characters in the generated string
var minSpecialChars int // track the minimum number of special characters to accept
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // default character set used for all strings
if complex {
minSpecialChars = int(math.Round(float64(length) * complexity)) // determine minimum number of special characters to accept
charset = charset + extendedCharset
}
// loop until a string of the desired complexity is generated
for {
// generate a random string
result := make([]byte, length)
for i := range result {
val, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
result[i] = charset[val.Int64()]
}
// return early if the string is not complex
if !complex {
return string(result)
}
// count the number of special characters in the generated string
for _, char := range string(result) {
if strings.ContainsRune(extendedCharset, char) {
actualSpecialChars++
}
}
// return the generated string if it contains enough special characters
if actualSpecialChars >= minSpecialChars {
return string(result)
}
// reset special character counter
fmt.Println("Regenerating string until desired complexity is achieved...")
actualSpecialChars = 0
}
}
// EntryIsNotEmpty iterates through entryData and returns true if any line is not empty
func EntryIsNotEmpty(entryData []string) bool {
for _, line := range entryData {
if line != "" {
return true
}
}
return false
}