commit d6c74ab155c60d431d0658edabe199f475c61ccf
parent 03df3eea7f46c8a3f81d624a684f01e321b05027
Author: Sean Enck <sean@ttypty.com>
Date: Sat, 7 Dec 2024 10:14:37 -0500
refactor config -> core to contain less common code needs
Diffstat:
21 files changed, 645 insertions(+), 565 deletions(-)
diff --git a/internal/app/core.go b/internal/app/core.go
@@ -254,8 +254,8 @@ func Usage(verbose bool, exe string) ([]string, error) {
CompletionsEnv: config.EnvDefaultCompletionKey,
ExampleTOML: config.ExampleTOML,
}
- document.ReKey.KeyFile = setDocFlag(config.ReKeyFlags.KeyFile)
- document.ReKey.NoKey = config.ReKeyFlags.NoKey
+ document.ReKey.KeyFile = setDocFlag(reKeyFlags.KeyFile)
+ document.ReKey.NoKey = reKeyFlags.NoKey
document.Hooks.Mode.Pre = string(backend.HookPre)
document.Hooks.Mode.Post = string(backend.HookPost)
document.Hooks.Action.Insert = string(backend.InsertAction)
diff --git a/internal/app/rekey.go b/internal/app/rekey.go
@@ -2,13 +2,25 @@
package app
import (
- "github.com/seanenck/lockbox/internal/config"
+ "errors"
+ "flag"
+ "strings"
)
+var reKeyFlags = struct {
+ KeyFile string
+ NoKey string
+}{"keyfile", "nokey"}
+
+type reKeyArgs struct {
+ NoKey bool
+ KeyFile string
+}
+
// ReKey handles entry rekeying
func ReKey(cmd UserInputOptions) error {
args := cmd.Args()
- vars, err := config.GetReKey(args)
+ vars, err := readArgs(args)
if err != nil {
return err
}
@@ -28,3 +40,18 @@ func ReKey(cmd UserInputOptions) error {
}
return cmd.Transaction().ReKey(pass, vars.KeyFile)
}
+
+func readArgs(args []string) (reKeyArgs, error) {
+ set := flag.NewFlagSet("rekey", flag.ExitOnError)
+ keyFile := set.String(reKeyFlags.KeyFile, "", "new keyfile")
+ noKey := set.Bool(reKeyFlags.NoKey, false, "disable password/key credential")
+ if err := set.Parse(args); err != nil {
+ return reKeyArgs{}, err
+ }
+ noPass := *noKey
+ file := *keyFile
+ if strings.TrimSpace(file) == "" && noPass {
+ return reKeyArgs{}, errors.New("a key or keyfile must be passed for rekey")
+ }
+ return reKeyArgs{KeyFile: file, NoKey: noPass}, nil
+}
diff --git a/internal/app/rekey_test.go b/internal/app/rekey_test.go
@@ -75,3 +75,19 @@ func TestReKeyPipe(t *testing.T) {
t.Errorf("invalid error: %v", err)
}
}
+
+func TestReKeyFlags(t *testing.T) {
+ newMockCommand(t)
+ mock := &mockKeyer{}
+ mock.t = t
+ mock.args = []string{"-nokey"}
+ if err := app.ReKey(mock); err == nil || err.Error() != "a key or keyfile must be passed for rekey" {
+ t.Errorf("invalid error: %v", err)
+ }
+ mock.args = []string{"-nokey", "-keyfile", "blla"}
+ mock.confirm = true
+ mock.pipe = false
+ if err := app.ReKey(mock); err == nil || err.Error() != "no keyfile found on disk" {
+ t.Errorf("invalid error: %v", err)
+ }
+}
diff --git a/internal/app/totp.go b/internal/app/totp.go
@@ -12,6 +12,7 @@ import (
"github.com/seanenck/lockbox/internal/backend"
"github.com/seanenck/lockbox/internal/config"
+ "github.com/seanenck/lockbox/internal/core"
"github.com/seanenck/lockbox/internal/platform"
)
@@ -75,12 +76,12 @@ func clearFunc() {
fmt.Print("\033[H\033[2J")
}
-func colorWhenRules() ([]config.ColorWindow, error) {
+func colorWhenRules() ([]core.ColorWindow, error) {
envTime := config.EnvTOTPColorBetween.Get()
if envTime == config.TOTPDefaultBetween {
return config.TOTPDefaultColorWindow, nil
}
- return config.ParseColorWindow(envTime)
+ return core.ParseColorWindow(envTime)
}
func (w totpWrapper) generateCode() (string, error) {
diff --git a/internal/backend/query.go b/internal/backend/query.go
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/seanenck/lockbox/internal/config"
+ "github.com/seanenck/lockbox/internal/core"
"github.com/tobischo/gokeepasslib/v3"
)
@@ -174,16 +175,16 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) {
if err != nil {
return nil, err
}
- jsonMode := config.JSONOutputs.Blank
+ jsonMode := core.JSONOutputs.Blank
if args.Values == JSONValue {
- m, err := config.ParseJSONOutput()
+ m, err := core.ParseJSONOutput(config.EnvJSONMode.Get())
if err != nil {
return nil, err
}
jsonMode = m
}
var hashLength int
- if jsonMode == config.JSONOutputs.Hash {
+ if jsonMode == core.JSONOutputs.Hash {
hashLength, err = config.EnvJSONHashLength.Get()
if err != nil {
return nil, err
@@ -202,9 +203,9 @@ func (t *Transaction) QueryCallback(args QueryOptions) (QuerySeq2, error) {
case JSONValue:
data := ""
switch jsonMode {
- case config.JSONOutputs.Raw:
+ case core.JSONOutputs.Raw:
data = val
- case config.JSONOutputs.Hash:
+ case core.JSONOutputs.Hash:
data = fmt.Sprintf("%x", sha512.Sum512([]byte(val)))
if hashLength > 0 && len(data) > hashLength {
data = data[0:hashLength]
diff --git a/internal/config/core.go b/internal/config/core.go
@@ -3,29 +3,24 @@ package config
import (
"bytes"
- "errors"
"fmt"
+ "net/url"
"os"
- "os/exec"
"path/filepath"
- "reflect"
"sort"
- "strconv"
"strings"
+ "time"
+ "github.com/seanenck/lockbox/internal/core"
"mvdan.cc/sh/v3/shell"
)
const (
- colorWindowDelimiter = " "
- colorWindowSpan = ":"
- exampleColorWindow = "start" + colorWindowSpan + "end"
- yes = "true"
- no = "false"
- detectEnvironment = "detect"
- noEnvironment = "none"
- tomlFile = "lockbox.toml"
- unknownPlatform = ""
+ yes = "true"
+ no = "false"
+ detectEnvironment = "detect"
+ noEnvironment = "none"
+ tomlFile = "lockbox.toml"
// sub categories
clipCategory keyCategory = "CLIP_"
totpCategory keyCategory = "TOTP_"
@@ -39,89 +34,43 @@ const (
// NoValue are no (off) values
NoValue = no
// TemplateVariable is used to handle '$' in shell vars (due to expansion)
- TemplateVariable = "[%]"
- configDirName = "lockbox"
- configDir = ".config"
- environmentPrefix = "LOCKBOX_"
+ TemplateVariable = "[%]"
+ configDirName = "lockbox"
+ configDir = ".config"
+ environmentPrefix = "LOCKBOX_"
+ commandArgsExample = "[cmd args...]"
+ fileExample = "<file>"
+ detectedValue = "<detected>"
+ requiredKeyOrKeyFile = "a key, a key file, or both must be set"
+ // ModTimeFormat is the expected modtime format
+ ModTimeFormat = time.RFC3339
+ exampleColorWindow = "start" + core.ColorWindowSpan + "end"
)
var (
+ exampleColorWindows = []string{fmt.Sprintf("[%s]", strings.Join([]string{exampleColorWindow, exampleColorWindow, exampleColorWindow + "..."}, core.ColorWindowDelimiter))}
configDirOffsetFile = filepath.Join(configDirName, tomlFile)
xdgPaths = []string{configDirOffsetFile, tomlFile}
homePaths = []string{filepath.Join(configDir, configDirOffsetFile), filepath.Join(configDir, tomlFile)}
- exampleColorWindows = []string{fmt.Sprintf("[%s]", strings.Join([]string{exampleColorWindow, exampleColorWindow, exampleColorWindow + "..."}, colorWindowDelimiter))}
registry = map[string]printer{}
+ // TOTPDefaultColorWindow is the default coloring rules for totp
+ TOTPDefaultColorWindow = []core.ColorWindow{{Start: 0, End: 5}, {Start: 30, End: 35}}
+ // TOTPDefaultBetween is the default color window as a string
+ TOTPDefaultBetween = func() string {
+ var results []string
+ for _, w := range TOTPDefaultColorWindow {
+ results = append(results, fmt.Sprintf("%d%s%d", w.Start, core.ColorWindowSpan, w.End))
+ }
+ return strings.Join(results, core.ColorWindowDelimiter)
+ }()
)
type (
keyCategory string
- // JSONOutputMode is the output mode definition
- JSONOutputMode string
- // SystemPlatform represents the platform lockbox is running on.
- SystemPlatform string
- environmentBase struct {
- subKey string
- cat keyCategory
- desc string
- requirement string
- }
- environmentDefault[T any] struct {
- environmentBase
- defaultValue T
- }
- // EnvironmentInt are environment settings that are integers
- EnvironmentInt struct {
- environmentDefault[int]
- allowZero bool
- shortDesc string
- }
- // EnvironmentBool are environment settings that are booleans
- EnvironmentBool struct {
- environmentDefault[bool]
- }
- // EnvironmentString are string-based settings
- EnvironmentString struct {
- environmentDefault[string]
- canDefault bool
- allowed []string
- }
- // EnvironmentCommand are settings that are parsed as shell commands
- EnvironmentCommand struct {
- environmentBase
- }
- // EnvironmentFormatter allows for sending a string into a get request
- EnvironmentFormatter struct {
- environmentBase
- allowed string
- fxn func(string, string) string
- }
- printer interface {
+ printer interface {
values() (string, []string)
self() environmentBase
}
- // ColorWindow for handling terminal colors based on timing
- ColorWindow struct {
- Start int
- End int
- }
- // ReKeyArgs are the arguments for rekeying
- ReKeyArgs struct {
- NoKey bool
- KeyFile string
- }
- // PlatformTypes defines systems lockbox is known to run on or can run on
- PlatformTypes struct {
- MacOSPlatform SystemPlatform
- LinuxWaylandPlatform SystemPlatform
- LinuxXPlatform SystemPlatform
- WindowsLinuxPlatform SystemPlatform
- }
- // JSONOutputTypes indicate how JSON data can be exported for values
- JSONOutputTypes struct {
- Hash JSONOutputMode
- Blank JSONOutputMode
- Raw JSONOutputMode
- }
)
func shlex(in string) ([]string, error) {
@@ -140,190 +89,6 @@ func environOrDefault(envKey, defaultValue string) string {
return val
}
-func (e environmentBase) Key() string {
- return fmt.Sprintf(environmentPrefix+"%s%s", string(e.cat), e.subKey)
-}
-
-// Get will get the boolean value for the setting
-func (e EnvironmentBool) Get() (bool, error) {
- return parseStringYesNo(e, getExpand(e.Key()))
-}
-
-func parseStringYesNo(e EnvironmentBool, in string) (bool, error) {
- read := strings.ToLower(strings.TrimSpace(in))
- switch read {
- case no:
- return false, nil
- case yes:
- return true, nil
- case "":
- return e.defaultValue, nil
- }
-
- return false, fmt.Errorf("invalid yes/no env value for %s", e.Key())
-}
-
-// Get will get the integer value for the setting
-func (e EnvironmentInt) Get() (int, error) {
- val := e.defaultValue
- use := getExpand(e.Key())
- if use != "" {
- i, err := strconv.Atoi(use)
- if err != nil {
- return -1, err
- }
- invalid := false
- check := ""
- if e.allowZero {
- check = "="
- }
- switch i {
- case 0:
- invalid = !e.allowZero
- default:
- invalid = i < 0
- }
- if invalid {
- return -1, fmt.Errorf("%s must be >%s 0", e.shortDesc, check)
- }
- val = i
- }
- return val, nil
-}
-
-// Get will read the string from the environment
-func (e EnvironmentString) Get() string {
- if !e.canDefault {
- return getExpand(e.Key())
- }
- return environOrDefault(e.Key(), e.defaultValue)
-}
-
-// Get will read (and shlex) the value if set
-func (e EnvironmentCommand) Get() ([]string, error) {
- value := environOrDefault(e.Key(), "")
- if strings.TrimSpace(value) == "" {
- return nil, nil
- }
- return shlex(value)
-}
-
-// KeyValue will get the string representation of the key+value
-func (e environmentBase) KeyValue(value string) string {
- return fmt.Sprintf("%s=%s", e.Key(), value)
-}
-
-// Setenv will do an environment set for the value to key
-func (e environmentBase) Set(value string) error {
- unset, err := IsUnset(e.Key(), value)
- if err != nil {
- return err
- }
- if unset {
- return nil
- }
- return os.Setenv(e.Key(), value)
-}
-
-// Get will retrieve the value with the formatted input included
-func (e EnvironmentFormatter) Get(value string) string {
- return e.fxn(e.Key(), value)
-}
-
-func (e EnvironmentString) values() (string, []string) {
- return e.defaultValue, e.allowed
-}
-
-func (e environmentBase) self() environmentBase {
- return e
-}
-
-func (e EnvironmentBool) values() (string, []string) {
- val := no
- if e.defaultValue {
- val = yes
- }
- return val, []string{yes, no}
-}
-
-func (e EnvironmentInt) values() (string, []string) {
- return fmt.Sprintf("%d", e.defaultValue), []string{"<integer>"}
-}
-
-func (e EnvironmentFormatter) values() (string, []string) {
- return strings.ReplaceAll(strings.ReplaceAll(EnvTOTPFormat.Get("%s"), "%25s", "%s"), "&", " \\\n &"), []string{e.allowed}
-}
-
-func (e EnvironmentCommand) values() (string, []string) {
- return detectedValue, []string{commandArgsExample}
-}
-
-// NewPlatform gets a new system platform.
-func NewPlatform() (SystemPlatform, error) {
- env := EnvPlatform.Get()
- if env != "" {
- for _, p := range EnvPlatform.allowed {
- if p == env {
- return SystemPlatform(p), nil
- }
- }
- return unknownPlatform, errors.New("unknown platform mode")
- }
- b, err := exec.Command("uname", "-a").Output()
- if err != nil {
- return unknownPlatform, err
- }
- raw := strings.ToLower(strings.TrimSpace(string(b)))
- parts := strings.Split(raw, " ")
- switch parts[0] {
- case "darwin":
- return Platforms.MacOSPlatform, nil
- case "linux":
- if strings.Contains(raw, "microsoft-standard-wsl") {
- return Platforms.WindowsLinuxPlatform, nil
- }
- if strings.TrimSpace(getExpand("WAYLAND_DISPLAY")) == "" {
- if strings.TrimSpace(getExpand("DISPLAY")) == "" {
- return unknownPlatform, errors.New("unable to detect linux clipboard mode")
- }
- return Platforms.LinuxXPlatform, nil
- }
- return Platforms.LinuxWaylandPlatform, nil
- }
- return unknownPlatform, errors.New("unable to detect clipboard mode")
-}
-
-// ParseColorWindow will handle parsing a window of colors for TOTP operations
-func ParseColorWindow(windowString string) ([]ColorWindow, error) {
- var rules []ColorWindow
- for _, item := range strings.Split(windowString, colorWindowDelimiter) {
- line := strings.TrimSpace(item)
- if line == "" {
- continue
- }
- parts := strings.Split(line, colorWindowSpan)
- if len(parts) != 2 {
- return nil, fmt.Errorf("invalid colorization rule found: %s", line)
- }
- s, err := strconv.Atoi(parts[0])
- if err != nil {
- return nil, err
- }
- e, err := strconv.Atoi(parts[1])
- if err != nil {
- return nil, err
- }
- if s < 0 || e < 0 || e < s || s > 59 || e > 59 {
- return nil, fmt.Errorf("invalid time found for colorization rule: %s", line)
- }
- rules = append(rules, ColorWindow{Start: s, End: e})
- }
- if len(rules) == 0 {
- return nil, errors.New("invalid colorization rules for totp, none found")
- }
- return rules, nil
-}
-
// NewConfigFiles will get the list of candidate config files
func NewConfigFiles() []string {
v := EnvConfig.Get()
@@ -437,29 +202,56 @@ func environmentRegister[T printer](obj T) T {
return obj
}
-// List will list the platform types on the struct
-func (p PlatformTypes) List() []string {
- return listFields[SystemPlatform](p)
-}
-
-// List will list the output modes on the struct
-func (p JSONOutputTypes) List() []string {
- return listFields[JSONOutputMode](p)
-}
-
-func listFields[T SystemPlatform | JSONOutputMode](p any) []string {
- v := reflect.ValueOf(p)
- var vals []string
- for i := 0; i < v.NumField(); i++ {
- vals = append(vals, fmt.Sprintf("%v", v.Field(i).Interface().(T)))
- }
- sort.Strings(vals)
- return vals
-}
-
func newDefaultedEnvironment[T any](val T, base environmentBase) environmentDefault[T] {
obj := environmentDefault[T]{}
obj.environmentBase = base
obj.defaultValue = val
return obj
}
+
+func formatterTOTP(key, value string) string {
+ const (
+ otpAuth = "otpauth"
+ otpIssuer = "lbissuer"
+ )
+ if strings.HasPrefix(value, otpAuth) {
+ return value
+ }
+ override := environOrDefault(key, "")
+ if override != "" {
+ return fmt.Sprintf(override, value)
+ }
+ v := url.Values{}
+ v.Set("secret", value)
+ v.Set("issuer", otpIssuer)
+ v.Set("period", "30")
+ v.Set("algorithm", "SHA1")
+ v.Set("digits", "6")
+ u := url.URL{
+ Scheme: otpAuth,
+ Host: "totp",
+ Path: "/" + otpIssuer + ":" + "lbaccount",
+ RawQuery: v.Encode(),
+ }
+ return u.String()
+}
+
+// CanColor indicates if colorized output is allowed (or disabled)
+func CanColor() (bool, error) {
+ if _, noColor := os.LookupEnv("NO_COLOR"); noColor {
+ return false, nil
+ }
+ interactive, err := EnvInteractive.Get()
+ if err != nil {
+ return false, err
+ }
+ colors := interactive
+ if colors {
+ isColored, err := EnvColorEnabled.Get()
+ if err != nil {
+ return false, err
+ }
+ colors = isColored
+ }
+ return colors, nil
+}
diff --git a/internal/config/core_test.go b/internal/config/core_test.go
@@ -9,17 +9,6 @@ import (
"github.com/seanenck/lockbox/internal/config"
)
-func TestList(t *testing.T) {
- for obj, cnt := range map[interface{ List() []string }]int{
- config.Platforms: 4,
- config.JSONOutputs: 3,
- } {
- if len(obj.List()) != cnt {
- t.Errorf("invalid list result: %v", obj)
- }
- }
-}
-
func isSet(key string) bool {
for _, item := range os.Environ() {
if strings.HasPrefix(item, fmt.Sprintf("%s=", key)) {
@@ -51,60 +40,6 @@ func TestKeyValue(t *testing.T) {
}
}
-func TestNewPlatform(t *testing.T) {
- for _, item := range config.Platforms.List() {
- t.Setenv("LOCKBOX_PLATFORM", item)
- s, err := config.NewPlatform()
- if err != nil {
- t.Errorf("invalid clipboard: %v", err)
- }
- if s != config.SystemPlatform(item) {
- t.Error("mismatch on input and resulting detection")
- }
- }
-}
-
-func TestNewPlatformUnknown(t *testing.T) {
- t.Setenv("LOCKBOX_PLATFORM", "afleaj")
- _, err := config.NewPlatform()
- if err == nil || err.Error() != "unknown platform mode" {
- t.Errorf("error expected for platform: %v", err)
- }
-}
-
-func TestParseWindows(t *testing.T) {
- if _, err := config.ParseColorWindow(""); err.Error() != "invalid colorization rules for totp, none found" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow(" 2"); err.Error() != "invalid colorization rule found: 2" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow(" 1:200"); err.Error() != "invalid time found for colorization rule: 1:200" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow(" 1:-1"); err.Error() != "invalid time found for colorization rule: 1:-1" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow(" 200:1"); err.Error() != "invalid time found for colorization rule: 200:1" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow(" -1:1"); err.Error() != "invalid time found for colorization rule: -1:1" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow(" 2:1"); err.Error() != "invalid time found for colorization rule: 2:1" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow("xxx:1"); err.Error() != "strconv.Atoi: parsing \"xxx\": invalid syntax" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow(" 1:xxx"); err.Error() != "strconv.Atoi: parsing \"xxx\": invalid syntax" {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := config.ParseColorWindow("1:2 11:22"); err != nil {
- t.Errorf("invalid error: %v", err)
- }
-}
-
func TestNewEnvFiles(t *testing.T) {
os.Clearenv()
t.Setenv("LOCKBOX_CONFIG_TOML", "none")
@@ -192,3 +127,30 @@ func TestWrap(t *testing.T) {
t.Errorf("invalid wrap: %s", w)
}
}
+
+func TestCanColor(t *testing.T) {
+ os.Clearenv()
+ if can, _ := config.CanColor(); !can {
+ t.Error("should be able to color")
+ }
+ for raw, expect := range map[string]bool{
+ "INTERACTIVE": true,
+ "COLOR_ENABLED": true,
+ } {
+ os.Clearenv()
+ key := fmt.Sprintf("LOCKBOX_%s", raw)
+ t.Setenv(key, "true")
+ if can, _ := config.CanColor(); can != expect {
+ t.Errorf("expect != actual: %s", key)
+ }
+ t.Setenv(key, "false")
+ if can, _ := config.CanColor(); can == expect {
+ t.Errorf("expect == actual: %s", key)
+ }
+ }
+ os.Clearenv()
+ t.Setenv("NO_COLOR", "1")
+ if can, _ := config.CanColor(); can {
+ t.Error("should NOT be able to color")
+ }
+}
diff --git a/internal/config/env.go b/internal/config/env.go
@@ -0,0 +1,166 @@
+// Package config handles user inputs/UI elements.
+package config
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+)
+
+type (
+ environmentBase struct {
+ subKey string
+ cat keyCategory
+ desc string
+ requirement string
+ }
+ environmentDefault[T any] struct {
+ environmentBase
+ defaultValue T
+ }
+ // EnvironmentInt are environment settings that are integers
+ EnvironmentInt struct {
+ environmentDefault[int]
+ allowZero bool
+ shortDesc string
+ }
+ // EnvironmentBool are environment settings that are booleans
+ EnvironmentBool struct {
+ environmentDefault[bool]
+ }
+ // EnvironmentString are string-based settings
+ EnvironmentString struct {
+ environmentDefault[string]
+ canDefault bool
+ allowed []string
+ }
+ // EnvironmentCommand are settings that are parsed as shell commands
+ EnvironmentCommand struct {
+ environmentBase
+ }
+ // EnvironmentFormatter allows for sending a string into a get request
+ EnvironmentFormatter struct {
+ environmentBase
+ allowed string
+ fxn func(string, string) string
+ }
+)
+
+func (e environmentBase) Key() string {
+ return fmt.Sprintf(environmentPrefix+"%s%s", string(e.cat), e.subKey)
+}
+
+// Get will get the boolean value for the setting
+func (e EnvironmentBool) Get() (bool, error) {
+ return parseStringYesNo(e, getExpand(e.Key()))
+}
+
+func parseStringYesNo(e EnvironmentBool, in string) (bool, error) {
+ read := strings.ToLower(strings.TrimSpace(in))
+ switch read {
+ case no:
+ return false, nil
+ case yes:
+ return true, nil
+ case "":
+ return e.defaultValue, nil
+ }
+
+ return false, fmt.Errorf("invalid yes/no env value for %s", e.Key())
+}
+
+// Get will get the integer value for the setting
+func (e EnvironmentInt) Get() (int, error) {
+ val := e.defaultValue
+ use := getExpand(e.Key())
+ if use != "" {
+ i, err := strconv.Atoi(use)
+ if err != nil {
+ return -1, err
+ }
+ invalid := false
+ check := ""
+ if e.allowZero {
+ check = "="
+ }
+ switch i {
+ case 0:
+ invalid = !e.allowZero
+ default:
+ invalid = i < 0
+ }
+ if invalid {
+ return -1, fmt.Errorf("%s must be >%s 0", e.shortDesc, check)
+ }
+ val = i
+ }
+ return val, nil
+}
+
+// Get will read the string from the environment
+func (e EnvironmentString) Get() string {
+ if !e.canDefault {
+ return getExpand(e.Key())
+ }
+ return environOrDefault(e.Key(), e.defaultValue)
+}
+
+// Get will read (and shlex) the value if set
+func (e EnvironmentCommand) Get() ([]string, error) {
+ value := environOrDefault(e.Key(), "")
+ if strings.TrimSpace(value) == "" {
+ return nil, nil
+ }
+ return shlex(value)
+}
+
+// KeyValue will get the string representation of the key+value
+func (e environmentBase) KeyValue(value string) string {
+ return fmt.Sprintf("%s=%s", e.Key(), value)
+}
+
+// Setenv will do an environment set for the value to key
+func (e environmentBase) Set(value string) error {
+ unset, err := IsUnset(e.Key(), value)
+ if err != nil {
+ return err
+ }
+ if unset {
+ return nil
+ }
+ return os.Setenv(e.Key(), value)
+}
+
+// Get will retrieve the value with the formatted input included
+func (e EnvironmentFormatter) Get(value string) string {
+ return e.fxn(e.Key(), value)
+}
+
+func (e EnvironmentString) values() (string, []string) {
+ return e.defaultValue, e.allowed
+}
+
+func (e environmentBase) self() environmentBase {
+ return e
+}
+
+func (e EnvironmentBool) values() (string, []string) {
+ val := no
+ if e.defaultValue {
+ val = yes
+ }
+ return val, []string{yes, no}
+}
+
+func (e EnvironmentInt) values() (string, []string) {
+ return fmt.Sprintf("%d", e.defaultValue), []string{"<integer>"}
+}
+
+func (e EnvironmentFormatter) values() (string, []string) {
+ return strings.ReplaceAll(strings.ReplaceAll(EnvTOTPFormat.Get("%s"), "%25s", "%s"), "&", " \\\n &"), []string{e.allowed}
+}
+
+func (e EnvironmentCommand) values() (string, []string) {
+ return detectedValue, []string{commandArgsExample}
+}
diff --git a/internal/config/vars.go b/internal/config/vars.go
@@ -2,53 +2,13 @@
package config
import (
- "errors"
- "flag"
"fmt"
- "net/url"
- "os"
"strings"
- "time"
-)
-const (
- commandArgsExample = "[cmd args...]"
- fileExample = "<file>"
- detectedValue = "<detected>"
- requiredKeyOrKeyFile = "a key, a key file, or both must be set"
- // ModTimeFormat is the expected modtime format
- ModTimeFormat = time.RFC3339
+ "github.com/seanenck/lockbox/internal/core"
)
var (
- // Platforms are the known platforms for lockbox
- Platforms = PlatformTypes{
- MacOSPlatform: "macos",
- LinuxWaylandPlatform: "linux-wayland",
- LinuxXPlatform: "linux-x",
- WindowsLinuxPlatform: "wsl",
- }
- // ReKeyFlags are the CLI argument flags for rekey handling
- ReKeyFlags = struct {
- KeyFile string
- NoKey string
- }{"keyfile", "nokey"}
- // JSONOutputs are the JSON data output types for exporting/output of values
- JSONOutputs = JSONOutputTypes{
- Hash: "hash",
- Blank: "empty",
- Raw: "plaintext",
- }
- // TOTPDefaultColorWindow is the default coloring rules for totp
- TOTPDefaultColorWindow = []ColorWindow{{Start: 0, End: 5}, {Start: 30, End: 35}}
- // TOTPDefaultBetween is the default color window as a string
- TOTPDefaultBetween = func() string {
- var results []string
- for _, w := range TOTPDefaultColorWindow {
- results = append(results, fmt.Sprintf("%d%s%d", w.Start, colorWindowSpan, w.End))
- }
- return strings.Join(results, colorWindowDelimiter)
- }()
// EnvClipTimeout gets the maximum clipboard time
EnvClipTimeout = environmentRegister(
EnvironmentInt{
@@ -68,7 +28,7 @@ var (
environmentBase{
cat: jsonCategory,
subKey: "HASH_LENGTH",
- desc: fmt.Sprintf("Maximum string length of the JSON value when '%s' mode is set for JSON output.", JSONOutputs.Hash),
+ desc: fmt.Sprintf("Maximum string length of the JSON value when '%s' mode is set for JSON output.", core.JSONOutputs.Hash),
}),
shortDesc: "hash length",
allowZero: true,
@@ -184,7 +144,7 @@ var (
subKey: "PLATFORM",
desc: "Override the detected platform.",
}),
- allowed: Platforms.List(),
+ allowed: core.Platforms.List(),
canDefault: false,
})
// EnvStore is the location of the keepass file/store
@@ -232,7 +192,7 @@ var (
cat: totpCategory,
desc: fmt.Sprintf(`Override when to set totp generated outputs to different colors,
must be a list of one (or more) rules where a '%s' delimits the start and end second (0-60 for each),
-and '%s' allows for multiple windows.`, colorWindowSpan, colorWindowDelimiter),
+and '%s' allows for multiple windows.`, core.ColorWindowSpan, core.ColorWindowDelimiter),
}),
canDefault: true,
allowed: exampleColorWindows,
@@ -265,14 +225,14 @@ and '%s' allows for multiple windows.`, colorWindowSpan, colorWindowDelimiter),
// EnvJSONMode controls how JSON is output in the 'data' field
EnvJSONMode = environmentRegister(
EnvironmentString{
- environmentDefault: newDefaultedEnvironment(string(JSONOutputs.Hash),
+ environmentDefault: newDefaultedEnvironment(string(core.JSONOutputs.Hash),
environmentBase{
cat: jsonCategory,
subKey: "MODE",
- desc: fmt.Sprintf("Changes what the data field in JSON outputs will contain.\n\nUse '%s' with CAUTION.", JSONOutputs.Raw),
+ desc: fmt.Sprintf("Changes what the data field in JSON outputs will contain.\n\nUse '%s' with CAUTION.", core.JSONOutputs.Raw),
}),
canDefault: true,
- allowed: JSONOutputs.List(),
+ allowed: core.JSONOutputs.List(),
})
// EnvTOTPFormat supports formatting the TOTP tokens for generation of tokens
EnvTOTPFormat = environmentRegister(EnvironmentFormatter{environmentBase: environmentBase{
@@ -400,76 +360,3 @@ Set to '%s' to ignore the set key value`, noKeyMode, IgnoreKeyMode),
canDefault: true,
})
)
-
-// GetReKey will get the rekey environment settings
-func GetReKey(args []string) (ReKeyArgs, error) {
- set := flag.NewFlagSet("rekey", flag.ExitOnError)
- keyFile := set.String(ReKeyFlags.KeyFile, "", "new keyfile")
- noKey := set.Bool(ReKeyFlags.NoKey, false, "disable password/key credential")
- if err := set.Parse(args); err != nil {
- return ReKeyArgs{}, err
- }
- noPass := *noKey
- file := *keyFile
- if strings.TrimSpace(file) == "" && noPass {
- return ReKeyArgs{}, errors.New("a key or keyfile must be passed for rekey")
- }
- return ReKeyArgs{KeyFile: file, NoKey: noPass}, nil
-}
-
-func formatterTOTP(key, value string) string {
- const (
- otpAuth = "otpauth"
- otpIssuer = "lbissuer"
- )
- if strings.HasPrefix(value, otpAuth) {
- return value
- }
- override := environOrDefault(key, "")
- if override != "" {
- return fmt.Sprintf(override, value)
- }
- v := url.Values{}
- v.Set("secret", value)
- v.Set("issuer", otpIssuer)
- v.Set("period", "30")
- v.Set("algorithm", "SHA1")
- v.Set("digits", "6")
- u := url.URL{
- Scheme: otpAuth,
- Host: "totp",
- Path: "/" + otpIssuer + ":" + "lbaccount",
- RawQuery: v.Encode(),
- }
- return u.String()
-}
-
-// ParseJSONOutput handles detecting the JSON output mode
-func ParseJSONOutput() (JSONOutputMode, error) {
- val := JSONOutputMode(strings.ToLower(strings.TrimSpace(EnvJSONMode.Get())))
- switch val {
- case JSONOutputs.Hash, JSONOutputs.Blank, JSONOutputs.Raw:
- return val, nil
- }
- return JSONOutputs.Blank, fmt.Errorf("invalid JSON output mode: %s", val)
-}
-
-// CanColor indicates if colorized output is allowed (or disabled)
-func CanColor() (bool, error) {
- if _, noColor := os.LookupEnv("NO_COLOR"); noColor {
- return false, nil
- }
- interactive, err := EnvInteractive.Get()
- if err != nil {
- return false, err
- }
- colors := interactive
- if colors {
- isColored, err := EnvColorEnabled.Get()
- if err != nil {
- return false, err
- }
- colors = isColored
- }
- return colors, nil
-}
diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go
@@ -2,7 +2,6 @@ package config_test
import (
"fmt"
- "os"
"testing"
"github.com/seanenck/lockbox/internal/config"
@@ -91,26 +90,6 @@ func TestTOTP(t *testing.T) {
}
}
-func TestReKey(t *testing.T) {
- if _, err := config.GetReKey([]string{"-nokey"}); err == nil || err.Error() != "a key or keyfile must be passed for rekey" {
- t.Errorf("failed: %v", err)
- }
- out, err := config.GetReKey([]string{})
- if err != nil {
- t.Errorf("failed: %v", err)
- }
- if out.NoKey || out.KeyFile != "" {
- t.Errorf("invalid args: %v", out)
- }
- out, err = config.GetReKey([]string{"-keyfile", "vars.go", "-nokey"})
- if err != nil {
- t.Errorf("failed: %v", err)
- }
- if !out.NoKey || out.KeyFile != "vars.go" {
- t.Errorf("invalid args: %v", out)
- }
-}
-
func TestFormatTOTP(t *testing.T) {
otp := config.EnvTOTPFormat.Get("otpauth://abc")
if otp != "otpauth://abc" {
@@ -132,32 +111,6 @@ func TestFormatTOTP(t *testing.T) {
}
}
-func TestParseJSONMode(t *testing.T) {
- m, err := config.ParseJSONOutput()
- if m != config.JSONOutputs.Hash || err != nil {
- t.Error("invalid mode read")
- }
- t.Setenv("LOCKBOX_JSON_MODE", "hAsH ")
- m, err = config.ParseJSONOutput()
- if m != config.JSONOutputs.Hash || err != nil {
- t.Error("invalid mode read")
- }
- t.Setenv("LOCKBOX_JSON_MODE", "EMPTY")
- m, err = config.ParseJSONOutput()
- if m != config.JSONOutputs.Blank || err != nil {
- t.Error("invalid mode read")
- }
- t.Setenv("LOCKBOX_JSON_MODE", " PLAINtext ")
- m, err = config.ParseJSONOutput()
- if m != config.JSONOutputs.Raw || err != nil {
- t.Error("invalid mode read")
- }
- t.Setenv("LOCKBOX_JSON_MODE", "a")
- if _, err = config.ParseJSONOutput(); err == nil || err.Error() != "invalid JSON output mode: a" {
- t.Errorf("invalid error: %v", err)
- }
-}
-
func TestClipboardMax(t *testing.T) {
checkInt(config.EnvClipTimeout, "LOCKBOX_CLIP_TIMEOUT", "clipboard max time", 45, false, t)
}
@@ -209,30 +162,3 @@ func checkInt(e config.EnvironmentInt, key, text string, def int, allowZero bool
}
}
}
-
-func TestCanColor(t *testing.T) {
- os.Clearenv()
- if can, _ := config.CanColor(); !can {
- t.Error("should be able to color")
- }
- for raw, expect := range map[string]bool{
- "INTERACTIVE": true,
- "COLOR_ENABLED": true,
- } {
- os.Clearenv()
- key := fmt.Sprintf("LOCKBOX_%s", raw)
- t.Setenv(key, "true")
- if can, _ := config.CanColor(); can != expect {
- t.Errorf("expect != actual: %s", key)
- }
- t.Setenv(key, "false")
- if can, _ := config.CanColor(); can == expect {
- t.Errorf("expect == actual: %s", key)
- }
- }
- os.Clearenv()
- t.Setenv("NO_COLOR", "1")
- if can, _ := config.CanColor(); can {
- t.Error("should NOT be able to color")
- }
-}
diff --git a/internal/core/colors.go b/internal/core/colors.go
@@ -0,0 +1,53 @@
+// Package core has to assist with some color components
+package core
+
+import (
+ "errors"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+const (
+ // ColorWindowDelimiter indicates how windows are split in env/config keys
+ ColorWindowDelimiter = " "
+ // ColorWindowSpan indicates the delineation betwee start -> end (start:end)
+ ColorWindowSpan = ":"
+)
+
+// ColorWindow for handling terminal colors based on timing
+type ColorWindow struct {
+ Start int
+ End int
+}
+
+// ParseColorWindow will handle parsing a window of colors for TOTP operations
+func ParseColorWindow(windowString string) ([]ColorWindow, error) {
+ var rules []ColorWindow
+ for _, item := range strings.Split(windowString, ColorWindowDelimiter) {
+ line := strings.TrimSpace(item)
+ if line == "" {
+ continue
+ }
+ parts := strings.Split(line, ColorWindowSpan)
+ if len(parts) != 2 {
+ return nil, fmt.Errorf("invalid colorization rule found: %s", line)
+ }
+ s, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return nil, err
+ }
+ e, err := strconv.Atoi(parts[1])
+ if err != nil {
+ return nil, err
+ }
+ if s < 0 || e < 0 || e < s || s > 59 || e > 59 {
+ return nil, fmt.Errorf("invalid time found for colorization rule: %s", line)
+ }
+ rules = append(rules, ColorWindow{Start: s, End: e})
+ }
+ if len(rules) == 0 {
+ return nil, errors.New("invalid colorization rules for totp, none found")
+ }
+ return rules, nil
+}
diff --git a/internal/core/colors_test.go b/internal/core/colors_test.go
@@ -0,0 +1,40 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/seanenck/lockbox/internal/core"
+)
+
+func TestParseWindows(t *testing.T) {
+ if _, err := core.ParseColorWindow(""); err.Error() != "invalid colorization rules for totp, none found" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow(" 2"); err.Error() != "invalid colorization rule found: 2" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow(" 1:200"); err.Error() != "invalid time found for colorization rule: 1:200" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow(" 1:-1"); err.Error() != "invalid time found for colorization rule: 1:-1" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow(" 200:1"); err.Error() != "invalid time found for colorization rule: 200:1" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow(" -1:1"); err.Error() != "invalid time found for colorization rule: -1:1" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow(" 2:1"); err.Error() != "invalid time found for colorization rule: 2:1" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow("xxx:1"); err.Error() != "strconv.Atoi: parsing \"xxx\": invalid syntax" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow(" 1:xxx"); err.Error() != "strconv.Atoi: parsing \"xxx\": invalid syntax" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := core.ParseColorWindow("1:2 11:22"); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+}
diff --git a/internal/core/core.go b/internal/core/core.go
@@ -0,0 +1,18 @@
+// Package core has helpers
+package core
+
+import (
+ "fmt"
+ "reflect"
+ "sort"
+)
+
+func listFields[T SystemPlatform | JSONOutputMode](p any) []string {
+ v := reflect.ValueOf(p)
+ var vals []string
+ for i := 0; i < v.NumField(); i++ {
+ vals = append(vals, fmt.Sprintf("%v", v.Field(i).Interface().(T)))
+ }
+ sort.Strings(vals)
+ return vals
+}
diff --git a/internal/core/json.go b/internal/core/json.go
@@ -0,0 +1,41 @@
+// Package core defines JSON outputs
+package core
+
+import (
+ "fmt"
+ "strings"
+)
+
+// JSONOutputs are the JSON data output types for exporting/output of values
+var JSONOutputs = JSONOutputTypes{
+ Hash: "hash",
+ Blank: "empty",
+ Raw: "plaintext",
+}
+
+type (
+ // JSONOutputMode is the output mode definition
+ JSONOutputMode string
+
+ // JSONOutputTypes indicate how JSON data can be exported for values
+ JSONOutputTypes struct {
+ Hash JSONOutputMode
+ Blank JSONOutputMode
+ Raw JSONOutputMode
+ }
+)
+
+// List will list the output modes on the struct
+func (p JSONOutputTypes) List() []string {
+ return listFields[JSONOutputMode](p)
+}
+
+// ParseJSONOutput handles detecting the JSON output mode
+func ParseJSONOutput(value string) (JSONOutputMode, error) {
+ val := JSONOutputMode(strings.ToLower(strings.TrimSpace(value)))
+ switch val {
+ case JSONOutputs.Hash, JSONOutputs.Blank, JSONOutputs.Raw:
+ return val, nil
+ }
+ return JSONOutputs.Blank, fmt.Errorf("invalid JSON output mode: %s", val)
+}
diff --git a/internal/core/json_test.go b/internal/core/json_test.go
@@ -0,0 +1,31 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/seanenck/lockbox/internal/core"
+)
+
+func TestJSONList(t *testing.T) {
+ if len(core.JSONOutputs.List()) != 3 {
+ t.Errorf("invalid list result")
+ }
+}
+
+func TestParseJSONMode(t *testing.T) {
+ m, err := core.ParseJSONOutput("hAsH ")
+ if m != core.JSONOutputs.Hash || err != nil {
+ t.Error("invalid mode read")
+ }
+ m, err = core.ParseJSONOutput("EMPTY")
+ if m != core.JSONOutputs.Blank || err != nil {
+ t.Error("invalid mode read")
+ }
+ m, err = core.ParseJSONOutput(" PLAINtext ")
+ if m != core.JSONOutputs.Raw || err != nil {
+ t.Error("invalid mode read")
+ }
+ if _, err = core.ParseJSONOutput("a"); err == nil || err.Error() != "invalid JSON output mode: a" {
+ t.Errorf("invalid error: %v", err)
+ }
+}
diff --git a/internal/core/platforms.go b/internal/core/platforms.go
@@ -0,0 +1,28 @@
+// Package core defines known platforms
+package core
+
+// Platforms are the known platforms for lockbox
+var Platforms = PlatformTypes{
+ MacOSPlatform: "macos",
+ LinuxWaylandPlatform: "linux-wayland",
+ LinuxXPlatform: "linux-x",
+ WindowsLinuxPlatform: "wsl",
+}
+
+type (
+ // SystemPlatform represents the platform lockbox is running on.
+ SystemPlatform string
+
+ // PlatformTypes defines systems lockbox is known to run on or can run on
+ PlatformTypes struct {
+ MacOSPlatform SystemPlatform
+ LinuxWaylandPlatform SystemPlatform
+ LinuxXPlatform SystemPlatform
+ WindowsLinuxPlatform SystemPlatform
+ }
+)
+
+// List will list the platform types on the struct
+func (p PlatformTypes) List() []string {
+ return listFields[SystemPlatform](p)
+}
diff --git a/internal/core/platforms_test.go b/internal/core/platforms_test.go
@@ -0,0 +1,13 @@
+package core_test
+
+import (
+ "testing"
+
+ "github.com/seanenck/lockbox/internal/core"
+)
+
+func TestPlatformList(t *testing.T) {
+ if len(core.Platforms.List()) != 4 {
+ t.Errorf("invalid list result")
+ }
+}
diff --git a/internal/platform/clipboard.go b/internal/platform/clipboard.go
@@ -9,6 +9,7 @@ import (
osc "github.com/aymanbagabas/go-osc52"
"github.com/seanenck/lockbox/internal/config"
+ "github.com/seanenck/lockbox/internal/core"
)
type (
@@ -57,7 +58,7 @@ func NewClipboard() (Clipboard, error) {
c := Clipboard{isOSC52: true}
return c, nil
}
- sys, err := config.NewPlatform()
+ sys, err := NewPlatform()
if err != nil {
return Clipboard{}, err
}
@@ -65,16 +66,16 @@ func NewClipboard() (Clipboard, error) {
var copying []string
var pasting []string
switch sys {
- case config.Platforms.MacOSPlatform:
+ case core.Platforms.MacOSPlatform:
copying = []string{"pbcopy"}
pasting = []string{"pbpaste"}
- case config.Platforms.LinuxXPlatform:
+ case core.Platforms.LinuxXPlatform:
copying = []string{"xclip"}
pasting = []string{"xclip", "-o"}
- case config.Platforms.LinuxWaylandPlatform:
+ case core.Platforms.LinuxWaylandPlatform:
copying = []string{"wl-copy"}
pasting = []string{"wl-paste"}
- case config.Platforms.WindowsLinuxPlatform:
+ case core.Platforms.WindowsLinuxPlatform:
copying = []string{"clip.exe"}
pasting = []string{"powershell.exe", "-command", "Get-Clipboard"}
default:
diff --git a/internal/platform/clipboard_test.go b/internal/platform/clipboard_test.go
@@ -4,7 +4,7 @@ import (
"fmt"
"testing"
- "github.com/seanenck/lockbox/internal/config"
+ "github.com/seanenck/lockbox/internal/core"
"github.com/seanenck/lockbox/internal/platform"
)
@@ -21,7 +21,7 @@ func TestNoClipboard(t *testing.T) {
func TestMaxTime(t *testing.T) {
t.Setenv("LOCKBOX_CLIP_ENABLED", "true")
t.Setenv("LOCKBOX_CLIP_OSC52", "false")
- t.Setenv("LOCKBOX_PLATFORM", string(config.Platforms.LinuxWaylandPlatform))
+ t.Setenv("LOCKBOX_PLATFORM", string(core.Platforms.LinuxWaylandPlatform))
t.Setenv("LOCKBOX_CLIP_TIMEOUT", "")
c, err := platform.NewClipboard()
if err != nil {
@@ -54,7 +54,7 @@ func TestClipboardInstances(t *testing.T) {
t.Setenv("LOCKBOX_CLIP_ENABLED", "true")
t.Setenv("LOCKBOX_CLIP_TIMEOUT", "")
t.Setenv("LOCKBOX_CLIP_OSC52", "false")
- for _, item := range config.Platforms.List() {
+ for _, item := range core.Platforms.List() {
t.Setenv("LOCKBOX_PLATFORM", item)
_, err := platform.NewClipboard()
if err != nil {
@@ -79,7 +79,7 @@ func TestOSC52(t *testing.T) {
func TestArgsOverride(t *testing.T) {
t.Setenv("LOCKBOX_CLIP_PASTE_COMMAND", "abc xyz 111")
t.Setenv("LOCKBOX_CLIP_OSC52", "false")
- t.Setenv("LOCKBOX_PLATFORM", string(config.Platforms.WindowsLinuxPlatform))
+ t.Setenv("LOCKBOX_PLATFORM", string(core.Platforms.WindowsLinuxPlatform))
c, _ := platform.NewClipboard()
cmd, args, ok := c.Args(true)
if cmd != "clip.exe" || len(args) != 0 || !ok {
diff --git a/internal/platform/detect.go b/internal/platform/detect.go
@@ -0,0 +1,48 @@
+package platform
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/seanenck/lockbox/internal/config"
+ "github.com/seanenck/lockbox/internal/core"
+)
+
+const unknownPlatform = ""
+
+// NewPlatform gets a new system platform.
+func NewPlatform() (core.SystemPlatform, error) {
+ env := config.EnvPlatform.Get()
+ if env != "" {
+ for _, p := range core.Platforms.List() {
+ if p == env {
+ return core.SystemPlatform(p), nil
+ }
+ }
+ return unknownPlatform, errors.New("unknown platform mode")
+ }
+ b, err := exec.Command("uname", "-a").Output()
+ if err != nil {
+ return unknownPlatform, err
+ }
+ raw := strings.ToLower(strings.TrimSpace(string(b)))
+ parts := strings.Split(raw, " ")
+ switch parts[0] {
+ case "darwin":
+ return core.Platforms.MacOSPlatform, nil
+ case "linux":
+ if strings.Contains(raw, "microsoft-standard-wsl") {
+ return core.Platforms.WindowsLinuxPlatform, nil
+ }
+ if strings.TrimSpace(os.Getenv("WAYLAND_DISPLAY")) == "" {
+ if strings.TrimSpace(os.Getenv("DISPLAY")) == "" {
+ return unknownPlatform, errors.New("unable to detect linux clipboard mode")
+ }
+ return core.Platforms.LinuxXPlatform, nil
+ }
+ return core.Platforms.LinuxWaylandPlatform, nil
+ }
+ return unknownPlatform, errors.New("unable to detect clipboard mode")
+}
diff --git a/internal/platform/detect_test.go b/internal/platform/detect_test.go
@@ -0,0 +1,29 @@
+package platform_test
+
+import (
+ "testing"
+
+ "github.com/seanenck/lockbox/internal/core"
+ "github.com/seanenck/lockbox/internal/platform"
+)
+
+func TestNewPlatform(t *testing.T) {
+ for _, item := range core.Platforms.List() {
+ t.Setenv("LOCKBOX_PLATFORM", item)
+ s, err := platform.NewPlatform()
+ if err != nil {
+ t.Errorf("invalid clipboard: %v", err)
+ }
+ if s != core.SystemPlatform(item) {
+ t.Error("mismatch on input and resulting detection")
+ }
+ }
+}
+
+func TestNewPlatformUnknown(t *testing.T) {
+ t.Setenv("LOCKBOX_PLATFORM", "afleaj")
+ _, err := platform.NewPlatform()
+ if err == nil || err.Error() != "unknown platform mode" {
+ t.Errorf("error expected for platform: %v", err)
+ }
+}