diff --git a/src/backend/1globals.go b/src/backend/1globals.go new file mode 100644 index 0000000..47cc873 --- /dev/null +++ b/src/backend/1globals.go @@ -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" +) diff --git a/src/backend/2globalsUNIX.go b/src/backend/2globalsUNIX.go new file mode 100644 index 0000000..a6cf714 --- /dev/null +++ b/src/backend/2globalsUNIX.go @@ -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 +) diff --git a/src/backend/2globalsWIN.go b/src/backend/2globalsWIN.go new file mode 100644 index 0000000..6b8f596 --- /dev/null +++ b/src/backend/2globalsWIN.go @@ -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 +) diff --git a/src/backend/add.go b/src/backend/add.go new file mode 100644 index 0000000..258c9fb --- /dev/null +++ b/src/backend/add.go @@ -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) +} diff --git a/src/backend/configParser.go b/src/backend/configParser.go new file mode 100644 index 0000000..a5d1002 --- /dev/null +++ b/src/backend/configParser.go @@ -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 = +// textEditor = +// onlineMode = +// sshError = +// netPinEnabled = +// remoteUser = +// remoteIP = +// remotePort = +// identityFile = diff --git a/src/backend/copy.go b/src/backend/copy.go new file mode 100644 index 0000000..6794c3a --- /dev/null +++ b/src/backend/copy.go @@ -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 +} diff --git a/src/backend/copyDARWIN.go b/src/backend/copyDARWIN.go new file mode 100644 index 0000000..27eac2f --- /dev/null +++ b/src/backend/copyDARWIN.go @@ -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) +} diff --git a/src/backend/copyTERMUX.go b/src/backend/copyTERMUX.go new file mode 100644 index 0000000..7d37111 --- /dev/null +++ b/src/backend/copyTERMUX.go @@ -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) +} diff --git a/src/backend/copyUNIXGeneric.go b/src/backend/copyUNIXGeneric.go new file mode 100644 index 0000000..01a0221 --- /dev/null +++ b/src/backend/copyUNIXGeneric.go @@ -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) +} diff --git a/src/backend/copyWIN.go b/src/backend/copyWIN.go new file mode 100644 index 0000000..952f32a --- /dev/null +++ b/src/backend/copyWIN.go @@ -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) +} diff --git a/src/backend/edit.go b/src/backend/edit.go new file mode 100644 index 0000000..5e79680 --- /dev/null +++ b/src/backend/edit.go @@ -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 +} diff --git a/src/backend/gpg.go b/src/backend/gpg.go new file mode 100644 index 0000000..b398dd4 --- /dev/null +++ b/src/backend/gpg.go @@ -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 +} diff --git a/src/backend/init.go b/src/backend/init.go new file mode 100644 index 0000000..bcaf3df --- /dev/null +++ b/src/backend/init.go @@ -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) " +} diff --git a/src/backend/initUNIX.go b/src/backend/initUNIX.go new file mode 100644 index 0000000..e6cf4de --- /dev/null +++ b/src/backend/initUNIX.go @@ -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 +} diff --git a/src/backend/initWIN.go b/src/backend/initWIN.go new file mode 100644 index 0000000..c228495 --- /dev/null +++ b/src/backend/initWIN.go @@ -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 +} diff --git a/src/backend/shear.go b/src/backend/shear.go new file mode 100644 index 0000000..fcd5479 --- /dev/null +++ b/src/backend/shear.go @@ -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) +} diff --git a/src/backend/targetLocationFormatUNIX.go b/src/backend/targetLocationFormatUNIX.go new file mode 100644 index 0000000..5e6dee7 --- /dev/null +++ b/src/backend/targetLocationFormatUNIX.go @@ -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 +} diff --git a/src/backend/targetLocationFormatWIN.go b/src/backend/targetLocationFormatWIN.go new file mode 100644 index 0000000..adbcb84 --- /dev/null +++ b/src/backend/targetLocationFormatWIN.go @@ -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) +} diff --git a/src/backend/utilitiesMisc.go b/src/backend/utilitiesMisc.go new file mode 100644 index 0000000..3c10f3c --- /dev/null +++ b/src/backend/utilitiesMisc.go @@ -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 +}