diff --git a/core/configParser.go b/cfg/configParser.go similarity index 96% rename from core/configParser.go rename to cfg/configParser.go index 8a62b78..8471171 100644 --- a/core/configParser.go +++ b/cfg/configParser.go @@ -1,4 +1,4 @@ -package core +package cfg import ( "fmt" @@ -36,7 +36,7 @@ func ParseConfig(valuesRequested [][2]string, missingValueError string) ([]strin if value == "" { switch missingValueError { case "": - err = fmt.Errorf("Failed to find value for key \"%s\" in section \"[%s]\" in libmutton.ini", pair[1], pair[0]) + err = fmt.Errorf("failed to find value for key \"%s\" in section \"[%s]\" in libmutton.ini", pair[1], pair[0]) case "0": back.Exit(0) // hard (expected) exit for CLI; GUI/TUI continue silently default: @@ -72,7 +72,7 @@ func WriteConfig(valuesToWrite [][3]string, keysToPrune [][2]string, append bool var section *ini.Section for _, trio := range valuesToWrite { if cfg.Section(trio[0]) == nil { - // create and aquire section if it doesn't exist + // create and acquire section if it doesn't exist section, _ = cfg.NewSection(trio[0]) } else { // acquire existing section diff --git a/core/utilitiesUNIX.go b/cfg/umaskUNIX.go similarity index 95% rename from core/utilitiesUNIX.go rename to cfg/umaskUNIX.go index 6bb17d7..0ee5007 100644 --- a/core/utilitiesUNIX.go +++ b/cfg/umaskUNIX.go @@ -1,6 +1,6 @@ //go:build !windows -package core +package cfg import "syscall" diff --git a/core/utilitiesWIN.go b/cfg/umaskWIN.go similarity index 88% rename from core/utilitiesWIN.go rename to cfg/umaskWIN.go index f79f771..9c1361a 100644 --- a/core/utilitiesWIN.go +++ b/cfg/umaskWIN.go @@ -1,6 +1,6 @@ //go:build windows -package core +package cfg // setUmask is a dummy function on Windows. func setUmask(umask int) { diff --git a/core/init.go b/core/init.go index ce5dca7..a03e387 100644 --- a/core/init.go +++ b/core/init.go @@ -1,15 +1,76 @@ package core import ( + "cmp" + "errors" + "strconv" + "strings" + "github.com/rwinkhart/go-boilerplate/back" + "github.com/rwinkhart/libmutton/cfg" "github.com/rwinkhart/libmutton/global" + "github.com/rwinkhart/libmutton/synccycles" "github.com/rwinkhart/rcw/wrappers" ) -// RCWSanityCheckGen generates the RCW sanity check file for libmutton. -func RCWSanityCheckGen(passphrase []byte) { - err := wrappers.GenSanityCheck(global.ConfigDir+global.PathSeparator+"sanity.rcw", passphrase) - if err != nil { - back.PrintError("Failed to generate sanity check file: "+err.Error(), back.ErrorWrite, true) +// LibmuttonInit creates the libmutton config structure based on user input. +// rcwPassphrase and clientSpecificIniData are cab be left blank if not needed. +func LibmuttonInit(inputCB func(prompt string) string, clientSpecificIniData [][3]string, rcwPassphrase []byte, preserveOldConfigDir bool) error { + r := strings.ToLower(inputCB("Configure SSH settings (for synchronization)? (y/N)")) + if len(r) > 0 && r[0] == 'y' { + // ensure ssh key file exists + fallbackSSHKey := back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "id_ed25519" + sshKeyPath := cmp.Or(back.ExpandPathWithHome(inputCB(back.AnsiBold+"Note:"+back.AnsiReset+" Only key-based authentication is supported (keys may optionally be passphrase-protected).\n The remote server must already be in your ~"+global.PathSeparator+".ssh"+global.PathSeparator+"known_hosts file.\n\nSSH private identity file path (falls back to \""+fallbackSSHKey+"\"):")), fallbackSSHKey) + sshKeyIsFile, _ := back.TargetIsFile(sshKeyPath, false, 0) + if !sshKeyIsFile { + return errors.New("ssh identity file not found: " + sshKeyPath) + } + + // get other ssh info from user + var sshKeyProtected bool + r = strings.ToLower(inputCB("Is the identity file password-protected? (y/N)")) + if len(r) > 0 && r[0] == 'y' { + sshKeyProtected = true + } + sshUser := inputCB("Remote SSH username:") + sshIP := inputCB("Remote SSH IP/domain:") + sshPort := inputCB("Remote SSH port:") + + // perform operations based on collected user input + //// initialize libmutton directories + oldDeviceID := global.DirInit(preserveOldConfigDir) + //// write config file + //// temporarily assign sshEntryRoot and sshIsWindows to null to pass initial device ID registration + cfg.WriteConfig(append( + clientSpecificIniData, + [][3]string{ + {"LIBMUTTON", "sshUser", sshUser}, + {"LIBMUTTON", "sshIP", sshIP}, + {"LIBMUTTON", "sshPort", sshPort}, + {"LIBMUTTON", "sshKey", sshKeyPath}, + {"LIBMUTTON", "sshKeyProtected", strconv.FormatBool(sshKeyProtected)}, + {"LIBMUTTON", "sshEntryRoot", "null"}, + {"LIBMUTTON", "sshIsWindows", "false"}}...), nil, false) + // generate and register device ID + sshEntryRoot, sshIsWindows, err := synccycles.DeviceIDGen(oldDeviceID) + if err != nil { + return errors.New("failed to generate device ID: " + err.Error()) + } + cfg.WriteConfig([][3]string{{"LIBMUTTON", "sshEntryRoot", sshEntryRoot}, {"LIBMUTTON", "sshIsWindows", sshIsWindows}}, nil, true) + } else { + // initialize libmutton directories + global.DirInit(preserveOldConfigDir) + // write config file + if len(clientSpecificIniData) > 0 { // TODO test passing empty clientSpecificIniData + cfg.WriteConfig(clientSpecificIniData, nil, false) + } } + // generate rcw sanity check file (if requested) + if len(rcwPassphrase) > 0 { + err := wrappers.GenSanityCheck(global.ConfigDir+global.PathSeparator+"sanity.rcw", rcwPassphrase) + if err != nil { + return errors.New("failed to generate sanity check file: " + err.Error()) + } + } + return nil } diff --git a/core/utilitiesMisc.go b/core/utilitiesMisc.go index ab8bc5a..50749de 100644 --- a/core/utilitiesMisc.go +++ b/core/utilitiesMisc.go @@ -1,10 +1,6 @@ package core import ( - "crypto/rand" - "fmt" - "math" - "math/big" "os" "strings" @@ -72,64 +68,6 @@ func EntryAddPrecheck(targetLocation string) uint8 { return 0 } -// StringGen generates a random string of a specified length and complexity. -// Requires: complexity (minimum percentage of special characters to be returned in the generated string; set to 0 to generate a simple string), -// complexCharsetLevel (1 = safe for filenames, 2 = safe for most password entries, 3 = safe only for well-made password entries) -func StringGen(length int, complexity float64, complexCharsetLevel uint8) 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 - var extendedCharset string // additions to character set used for complex strings - - charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // default character set used for all strings - const extendedCharsetFiles = "!#$%&+,-.;=@_~^()[]{}`'" // additional special characters for complex strings (safe in file names) - const extendedCharsetMostPassword = "*:> 0 { - minSpecialChars = int(math.Round(float64(length) * complexity)) // determine minimum number of special characters to accept - switch complexCharsetLevel { - case 1: - extendedCharset = extendedCharsetFiles - case 2: - extendedCharset = extendedCharsetMostPassword + extendedCharsetFiles[:len(extendedCharsetFiles)-9] - case 3: - extendedCharset = extendedCharsetFiles + extendedCharsetMostPassword + extendedCharsetSpecialPassword - } - 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 complexity <= 0 { - 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 { diff --git a/go.mod b/go.mod index 81ee1d0..e8ab0e7 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.3 require ( github.com/pkg/sftp v1.13.9 github.com/pquerna/otp v1.5.0 - github.com/rwinkhart/go-boilerplate v0.0.0-20250509173525-20670ec7bb9c + github.com/rwinkhart/go-boilerplate v0.0.0-20250529185306-e2e64d7fa43b github.com/rwinkhart/rcw v0.2.0 golang.design/x/clipboard v0.7.0 // only for Android builds golang.org/x/crypto v0.38.0 diff --git a/go.sum b/go.sum index b74a3de..17c2e15 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/rwinkhart/go-boilerplate v0.0.0-20250509173525-20670ec7bb9c h1:RIMnYf1MwsvmAr9E0/cpn7rTh8BWdfsQ2r31ITKJp2A= -github.com/rwinkhart/go-boilerplate v0.0.0-20250509173525-20670ec7bb9c/go.mod h1:cnzIF45I0FCOvE4YIB+26pLCUx2kWyY2llKYZruNaRY= +github.com/rwinkhart/go-boilerplate v0.0.0-20250529185306-e2e64d7fa43b h1:ENgsUlCmYktd1eauEkjW6Fu8rgZzyGOS/m/6jc968xI= +github.com/rwinkhart/go-boilerplate v0.0.0-20250529185306-e2e64d7fa43b/go.mod h1:cnzIF45I0FCOvE4YIB+26pLCUx2kWyY2llKYZruNaRY= github.com/rwinkhart/go-winio-easy-pipe-handles v0.0.0-20250407031321-96994a0e8410 h1:NhHwFM3Pgm6zRUfFKvi0p5ndjfFbVWsRwmmhyFlG4PE= github.com/rwinkhart/go-winio-easy-pipe-handles v0.0.0-20250407031321-96994a0e8410/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/rwinkhart/peercred-mini v0.1.0 h1:TiS6u8cEWzW55S9X4iVpU72Iuy/NG6rMUJMtUEVEFLw= diff --git a/syncclient/client.go b/syncclient/client.go index 8ea448f..e49fa8c 100644 --- a/syncclient/client.go +++ b/syncclient/client.go @@ -1,6 +1,7 @@ package syncclient import ( + "errors" "fmt" "os" "strconv" @@ -9,7 +10,7 @@ import ( "github.com/pkg/sftp" "github.com/rwinkhart/go-boilerplate/back" - "github.com/rwinkhart/libmutton/core" + "github.com/rwinkhart/libmutton/cfg" "github.com/rwinkhart/libmutton/global" "github.com/rwinkhart/libmutton/synccommon" "golang.org/x/crypto/ssh" @@ -18,7 +19,7 @@ import ( // GetSSHClient returns an SSH client connection to the server (also returns the remote EntryRoot and an indicator of the server's OS). // Only supports key-based authentication (passphrases are supported for CLI-based implementations). -func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) { +func GetSSHClient(manualSync bool) (*ssh.Client, string, bool, error) { // get SSH config info, exit if not configured (displaying an error if the sync job was called manually) var sshUserConfig []string var missingValueError string @@ -27,7 +28,7 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) { } else { missingValueError = "0" // allow silent exit at this point in offline mode } - sshUserConfig, _ = core.ParseConfig([][2]string{{"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}}, missingValueError) + sshUserConfig, _ = cfg.ParseConfig([][2]string{{"LIBMUTTON", "sshUser"}, {"LIBMUTTON", "sshIP"}, {"LIBMUTTON", "sshPort"}, {"LIBMUTTON", "sshKey"}, {"LIBMUTTON", "sshKeyProtected"}, {"LIBMUTTON", "sshEntryRoot"}, {"LIBMUTTON", "sshIsWindows"}}, missingValueError) var user, ip, port, keyFile, keyFileProtected, entryRoot string var isWindows bool @@ -49,7 +50,7 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) { case 6: isWindows, err = strconv.ParseBool(key) if err != nil { - back.PrintError("Sync failed - Unable to parse server OS type: "+err.Error(), back.ErrorRead, true) + return nil, "", false, errors.New("unable to parse server OS type: " + err.Error()) } } } @@ -57,7 +58,7 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) { // read private key key, err := os.ReadFile(keyFile) if err != nil { - back.PrintError("Sync failed - Unable to read private key: "+keyFile, back.ErrorRead, true) + return nil, "", false, errors.New("unable to read private key: " + keyFile) } // parse private key @@ -68,14 +69,14 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) { parsedKey, err = ssh.ParsePrivateKeyWithPassphrase(key, global.GetPassphrase("Enter passphrase for your SSH keyfile:")) } if err != nil { - back.PrintError("Sync failed - Unable to parse private key: "+keyFile, back.ErrorRead, true) + return nil, "", false, errors.New("unable to parse private key: " + keyFile) } // read known hosts file var hostKeyCallback ssh.HostKeyCallback hostKeyCallback, err = knownhosts.New(back.Home + global.PathSeparator + ".ssh" + global.PathSeparator + "known_hosts") if err != nil { - back.PrintError("Sync failed - Unable to read known hosts file: "+err.Error(), back.ErrorRead, true) + return nil, "", false, errors.New("unable to read known hosts file: " + err.Error()) } // configure SSH client @@ -91,11 +92,10 @@ func GetSSHClient(manualSync bool) (*ssh.Client, string, bool) { // connect to SSH server sshClient, err := ssh.Dial("tcp", ip+":"+port, sshConfig) if err != nil { - back.PrintError("Sync failed - Unable to connect to remote server: "+err.Error(), global.ErrorServerConnection, false) // do not crash/close interactive clients - return nil, "", false + return nil, "", false, errors.New("unable to connect to remote server: " + err.Error()) } - return sshClient, entryRoot, isWindows + return sshClient, entryRoot, isWindows, nil } // GetSSHOutput runs a command over SSH and returns the output as a string. @@ -418,14 +418,17 @@ func folderSync(folders []string) { // Setting returnLists to true will return the deletions, downloads, and uploads lists for use by the client. func RunJob(manualSync, returnLists bool) [3][]string { // get SSH client to re-use throughout the sync process - sshClient, sshEntryRoot, sshIsWindows := GetSSHClient(manualSync) + sshClient, sshEntryRoot, sshIsWindows, err := GetSSHClient(manualSync) + if err != nil { + back.PrintError("sync failed - unable to connect to SSH client: "+err.Error(), global.ErrorServerConnection, true) + } if sshClient == nil { // indicate SSH dialing failure for interactive clients return [3][]string{nil, nil, nil} } defer func(sshClient *ssh.Client) { err := sshClient.Close() if err != nil { - back.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true) + back.PrintError("sync failed - unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true) } }(sshClient) diff --git a/syncclient/oneOff.go b/syncclient/oneOff.go index 2b0290a..7be62ca 100644 --- a/syncclient/oneOff.go +++ b/syncclient/oneOff.go @@ -15,7 +15,10 @@ func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) { if !forceOffline && deviceID != "" { // ensure a device ID exists (online mode) // create an SSH client; manualSync is false in case a device ID exists but SSH is not configured - sshClient, _, _ := GetSSHClient(false) + sshClient, _, _, err := GetSSHClient(false) + if err != nil { + back.PrintError("Sync failed - Unable to connect to SSH client: "+err.Error(), global.ErrorServerConnection, true) + } // ensure targetLocationIncomplete ends with a slash if it is a directory (for clarity in shear message) if isDir && !strings.HasSuffix(targetLocationIncomplete, "/") { @@ -26,7 +29,7 @@ func ShearRemoteFromClient(targetLocationIncomplete string, forceOffline bool) { GetSSHOutput(sshClient, "libmuttonserver shear", deviceID+"\n"+strings.ReplaceAll(targetLocationIncomplete, global.PathSeparator, global.FSPath)) // close the SSH client - err := sshClient.Close() + err = sshClient.Close() if err != nil { back.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true) } @@ -43,7 +46,10 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, deviceIDList := global.GenDeviceIDList(true) if !forceOffline && len(deviceIDList) > 0 { // ensure a device ID exists (online mode) // create an SSH client; manualSync is false in case a device ID exists but SSH is not configured - sshClient, _, _ := GetSSHClient(false) + sshClient, _, _, err := GetSSHClient(false) + if err != nil { + back.PrintError("Sync failed - Unable to connect to SSH client: "+err.Error(), global.ErrorServerConnection, true) + } // call the server to move the target on the remote system and add the old target to the deletions list GetSSHOutput(sshClient, "libmuttonserver rename", @@ -52,7 +58,7 @@ func RenameRemoteFromClient(oldLocationIncomplete, newLocationIncomplete string, strings.ReplaceAll(newLocationIncomplete, global.PathSeparator, global.FSPath)) // close the SSH client - err := sshClient.Close() + err = sshClient.Close() if err != nil { back.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true) } @@ -69,13 +75,16 @@ func AddFolderRemoteFromClient(targetLocationIncomplete string, forceOffline boo deviceIDList := global.GenDeviceIDList(true) if !forceOffline && len(deviceIDList) > 0 { // ensure a device ID exists (online mode) // create an SSH client; manualSync is false in case a device ID exists but SSH is not configured - sshClient, _, _ := GetSSHClient(false) + sshClient, _, _, err := GetSSHClient(false) + if err != nil { + back.PrintError("Sync failed - Unable to connect to SSH client: "+err.Error(), global.ErrorServerConnection, true) + } // call the server to create the folder remotely GetSSHOutput(sshClient, "libmuttonserver addfolder", strings.ReplaceAll(targetLocationIncomplete, global.PathSeparator, global.FSPath)) // call the server to create the folder remotely // close the SSH client - err := sshClient.Close() + err = sshClient.Close() if err != nil { back.PrintError("Sync failed - Unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true) } diff --git a/syncclient/init.go b/synccycles/init.go similarity index 61% rename from syncclient/init.go rename to synccycles/init.go index f9cd4ce..dddbea5 100644 --- a/syncclient/init.go +++ b/synccycles/init.go @@ -1,49 +1,52 @@ -package syncclient +package synccycles import ( + "errors" "math/rand" "os" "strconv" "strings" "time" - "github.com/rwinkhart/go-boilerplate/back" - "github.com/rwinkhart/libmutton/core" "github.com/rwinkhart/libmutton/global" + "github.com/rwinkhart/libmutton/syncclient" ) // DeviceIDGen generates a new client device ID and registers it with the server (will replace existing one). // Device IDs are only needed for online synchronization. // Device IDs are guaranteed unique as the current UNIX time is appended to them. // Returns: the remote EntryRoot and OS type indicator. -func DeviceIDGen(oldDeviceID string) (string, string) { +func DeviceIDGen(oldDeviceID string) (string, string, error) { // generate new device ID deviceIDPrefix, _ := os.Hostname() - deviceIDSuffix := core.StringGen(rand.Intn(32)+48, 0.2, 1) + "-" + strconv.FormatInt(time.Now().Unix(), 10) + deviceIDSuffix := StringGen(rand.Intn(32)+48, 0.2, 1) + "-" + strconv.FormatInt(time.Now().Unix(), 10) newDeviceID := deviceIDPrefix + "-" + deviceIDSuffix // create new device ID file (locally) fileToClose, err := os.OpenFile(global.ConfigDir+global.PathSeparator+"devices"+global.PathSeparator+newDeviceID, os.O_CREATE|os.O_WRONLY, 0600) if err != nil { - back.PrintError("Failed to create local device ID file: "+err.Error(), back.ErrorWrite, true) + return "", "", errors.New("failed to create local device ID file: " + err.Error()) } _ = fileToClose.Close() // error ignored; if the file could be created, it can probably be closed // remove old device ID file (locally; may not exist) err = os.RemoveAll(global.ConfigDir + global.PathSeparator + "devices" + global.PathSeparator + oldDeviceID) if err != nil { - back.PrintError("Failed to remove old device ID file (locally): "+err.Error(), back.ErrorWrite, true) + return "", "", errors.New("failed to remove old device ID file (locally): " + err.Error()) } // register new device ID with server and fetch remote EntryRoot and OS type // also removes the old device ID file (remotely) // manualSync is true so the user is alerted if device ID registration fails - sshClient, _, _ := GetSSHClient(true) - sshEntryRootSSHIsWindows := strings.Split(GetSSHOutput(sshClient, "libmuttonserver register", newDeviceID+"\n"+oldDeviceID), global.FSSpace) + sshClient, _, _, err := syncclient.GetSSHClient(true) + if err != nil { + return "", "", errors.New("device ID gen failed - unable to connect to SSH client: " + err.Error()) + } + sshEntryRootSSHIsWindows := strings.Split(syncclient.GetSSHOutput(sshClient, "libmuttonserver register", newDeviceID+"\n"+oldDeviceID), global.FSSpace) err = sshClient.Close() if err != nil { - back.PrintError("Init failed - Unable to close SSH client: "+err.Error(), global.ErrorServerConnection, true) + return "", "", errors.New("device ID gen failed - unable to close SSH client: " + err.Error()) } - return sshEntryRootSSHIsWindows[0], sshEntryRootSSHIsWindows[1] + return sshEntryRootSSHIsWindows[0], sshEntryRootSSHIsWindows[1], nil } diff --git a/synccycles/utilities.go b/synccycles/utilities.go new file mode 100644 index 0000000..d59beba --- /dev/null +++ b/synccycles/utilities.go @@ -0,0 +1,67 @@ +package synccycles + +import ( + "crypto/rand" + "fmt" + "math" + "math/big" + "strings" +) + +// StringGen generates a random string of a specified length and complexity. +// Requires: complexity (minimum percentage of special characters to be returned in the generated string; set to 0 to generate a simple string), +// complexCharsetLevel (1 = safe for filenames, 2 = safe for most password entries, 3 = safe only for well-made password entries) +func StringGen(length int, complexity float64, complexCharsetLevel uint8) 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 + var extendedCharset string // additions to character set used for complex strings + + charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // default character set used for all strings + const extendedCharsetFiles = "!#$%&+,-.;=@_~^()[]{}`'" // additional special characters for complex strings (safe in file names) + const extendedCharsetMostPassword = "*:> 0 { + minSpecialChars = int(math.Round(float64(length) * complexity)) // determine minimum number of special characters to accept + switch complexCharsetLevel { + case 1: + extendedCharset = extendedCharsetFiles + case 2: + extendedCharset = extendedCharsetMostPassword + extendedCharsetFiles[:len(extendedCharsetFiles)-9] + case 3: + extendedCharset = extendedCharsetFiles + extendedCharsetMostPassword + extendedCharsetSpecialPassword + } + 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 complexity <= 0 { + 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 + } +}