lockbox

password manager
Log | Files | Refs | README | LICENSE

commit e787eca5c3ca20f001027a426044ce86f538233b
parent 7815c8a5625906a35d9d463bbdd9a05bd781c702
Author: Sean Enck <sean@ttypty.com>
Date:   Fri,  6 Dec 2024 18:46:04 -0500

from .env to .toml file

Diffstat:
Mcmd/main.go | 13+++++++------
Mgo.mod | 1+
Mgo.sum | 2++
Minternal/app/core_test.go | 2+-
Ainternal/config/config.toml | 46++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/core.go | 108+++++++++++--------------------------------------------------------------------
Minternal/config/core_test.go | 143+++++++++++--------------------------------------------------------------------
Ainternal/config/toml.go | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/config/toml_test.go | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/config/vars.go | 29+++++------------------------
Minternal/config/vars_test.go | 4++--
Minternal/platform/os.go | 39---------------------------------------
Minternal/platform/os_test.go | 57---------------------------------------------------------
Mjustfile | 4++--
Mtests/run.sh | 30++++++++++++++++++++++--------
15 files changed, 620 insertions(+), 356 deletions(-)

diff --git a/cmd/main.go b/cmd/main.go @@ -41,12 +41,13 @@ func handleEarly(command string, args []string) (bool, error) { } func run() error { - paths, err := config.NewEnvFiles() - if err != nil { - return err - } - if err := platform.LoadEnvConfigs(paths...); err != nil { - return err + for _, p := range config.NewConfigFiles() { + if platform.PathExists(p) { + if err := config.LoadConfigFile(p); err != nil { + return err + } + break + } } args := os.Args if len(args) < 2 { diff --git a/go.mod b/go.mod @@ -3,6 +3,7 @@ module github.com/seanenck/lockbox go 1.23.0 require ( + github.com/BurntSushi/toml v1.4.0 github.com/aymanbagabas/go-osc52 v1.2.2 github.com/pquerna/otp v1.4.0 github.com/tobischo/gokeepasslib/v3 v3.6.0 diff --git a/go.sum b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= +github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/aymanbagabas/go-osc52 v1.2.2 h1:NT7wkhEhPTcKnBCdPi9djmyy9L3JOL4+3SsfJyqptCo= github.com/aymanbagabas/go-osc52 v1.2.2/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= diff --git a/internal/app/core_test.go b/internal/app/core_test.go @@ -13,7 +13,7 @@ func TestUsage(t *testing.T) { t.Errorf("invalid usage, out of date? %d", len(u)) } u, _ = app.Usage(true, "lb") - if len(u) != 126 { + if len(u) != 124 { t.Errorf("invalid verbose usage, out of date? %d", len(u)) } for _, usage := range u { diff --git a/internal/config/config.toml b/internal/config/config.toml @@ -0,0 +1,46 @@ +language = "" +platform = "" +readonly = false +store = "" +include = [] + +[color] +enabled = true + +[clip] +copy = [] +paste = [] +max = 0 +osc52 = false +enabled = true + +[hook] +enabled = true +directory = "" + +[json] +mode = "" +hash_length = 0 + +[keys] +file = "" +mode = "" +key = "" + +[pwgen] +enabled = true +chars = "" +count = 0 +template = "" +title = true +wordlist = [] + +[totp] +enabled = true +attribute = "" +between = [] +format = "" +max = 0 + +[entry] +modtime = "" diff --git a/internal/config/core.go b/internal/config/core.go @@ -24,7 +24,7 @@ const ( no = "no" detectEnvironment = "detect" noEnvironment = "none" - envFile = "lockbox.env" + tomlFile = "lockbox.toml" unknownPlatform = "" // sub categories clipCategory keyCategory = "CLIP_" @@ -33,15 +33,16 @@ const ( // YesValue are yes (on) values YesValue = yes // TemplateVariable is used to handle '$' in shell vars (due to expansion) - TemplateVariable = "[%]" - configDirName = "lockbox" - configDir = ".config" + TemplateVariable = "[%]" + configDirName = "lockbox" + configDir = ".config" + environmentPrefix = "LOCKBOX_" ) var ( - configDirOffsetFile = filepath.Join(configDirName, envFile) - xdgPaths = []string{configDirOffsetFile, envFile} - homePaths = []string{filepath.Join(configDir, configDirOffsetFile), filepath.Join(configDir, envFile)} + configDirOffsetFile = filepath.Join(configDirName, tomlFile) + xdgPaths = []string{configDirOffsetFile, tomlFile} + homePaths = []string{filepath.Join(configDir, configDirOffsetFile), filepath.Join(configDir, tomlFile)} exampleColorWindows = []string{strings.Join([]string{exampleColorWindow, exampleColorWindow, exampleColorWindow + "..."}, colorWindowDelimiter)} registeredEnv = []printer{} ) @@ -134,7 +135,7 @@ func environOrDefault(envKey, defaultValue string) string { } func (e environmentBase) Key() string { - return fmt.Sprintf("LOCKBOX_%s%s", string(e.cat), e.subKey) + return fmt.Sprintf(environmentPrefix+"%s%s", string(e.cat), e.subKey) } // Get will get the boolean value for the setting @@ -319,16 +320,16 @@ func ParseColorWindow(windowString string) ([]ColorWindow, error) { // NewEnvFiles will get the list of candidate environment files // it will also set the environment to empty for the caller -func NewEnvFiles() ([]string, error) { +func NewConfigFiles() []string { v := EnvConfig.Get() if v == "" || v == noEnvironment { - return []string{}, nil + return []string{} } if err := EnvConfig.Set(noEnvironment); err != nil { - return nil, err + return nil } if v != detectEnvironment { - return []string{v}, nil + return []string{v} } var options []string pathAdder := func(root string, err error, subs []string) { @@ -341,10 +342,7 @@ func NewEnvFiles() ([]string, error) { pathAdder(os.Getenv("XDG_CONFIG_HOME"), nil, xdgPaths) h, err := os.UserHomeDir() pathAdder(h, err, homePaths) - if len(options) == 0 { - return nil, errors.New("unable to initialize default config locations") - } - return options, nil + return options } // IsUnset will indicate if a variable is an unset (and unset it) or return that it isn't @@ -375,84 +373,6 @@ func Environ() []string { return results } -func parseConfigKeyEarly[T any](env interface { - Key() string - Get() (T, error) -}, inputs map[string]string, conv func(string) (T, error), -) (T, error) { - raw, ok := inputs[env.Key()] - if ok { - return conv(raw) - } - return env.Get() -} - -// ExpandParsed handles cycles of parsing configuration env inputs to resolve ALL variables -func ExpandParsed(inputs map[string]string) (map[string]string, error) { - if inputs == nil { - return nil, errors.New("invalid input variables") - } - if len(inputs) == 0 { - return inputs, nil - } - cycles, err := parseConfigKeyEarly(envConfigExpands, inputs, strconv.Atoi) - if err != nil { - return nil, err - } - quoted, err := parseConfigKeyEarly(envConfigQuoted, inputs, func(v string) (bool, error) { - return parseStringYesNo(envConfigQuoted, v) - }) - if err != nil { - return nil, err - } - if cycles == 0 { - return inputs, nil - } - result := inputs - for cycles > 0 { - expanded := expandParsed(result, quoted) - if len(expanded) == len(result) { - same := true - for k, v := range expanded { - val, ok := result[k] - if !ok { - same = false - break - } - if val != v { - same = false - break - } - } - if same { - return expanded, nil - } - } - result = expanded - cycles-- - } - return nil, errors.New("reached maximum expand cycle count") -} - -func expandParsed(inputs map[string]string, quoted bool) map[string]string { - result := make(map[string]string) - for k, v := range inputs { - val := v - if quoted { - if strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"") { - val = strings.TrimPrefix(strings.TrimSuffix(val, "\""), "\"") - } - } - result[k] = os.Expand(val, func(in string) string { - if i, ok := inputs[in]; ok { - return i - } - return os.Getenv(in) - }) - } - return result -} - // Wrap performs simple block text word wrapping func Wrap(indent uint, in string) string { var sections []string diff --git a/internal/config/core_test.go b/internal/config/core_test.go @@ -107,39 +107,33 @@ func TestParseWindows(t *testing.T) { func TestNewEnvFiles(t *testing.T) { os.Clearenv() - t.Setenv("LOCKBOX_ENV", "none") - f, err := config.NewEnvFiles() - if len(f) != 0 || err != nil { - t.Errorf("invalid files: %v %v", f, err) + t.Setenv("LOCKBOX_CONFIG_TOML", "none") + f := config.NewConfigFiles() + if len(f) != 0 { + t.Errorf("invalid files: %v", f) } - t.Setenv("LOCKBOX_ENV", "test") - f, err = config.NewEnvFiles() - if len(f) != 1 || f[0] != "test" || err != nil { - t.Errorf("invalid files: %v %v", f, err) + t.Setenv("LOCKBOX_CONFIG_TOML", "test") + f = config.NewConfigFiles() + if len(f) != 1 || f[0] != "test" { + t.Errorf("invalid files: %v", f) } t.Setenv("HOME", "test") - t.Setenv("LOCKBOX_ENV", "detect") - f, err = config.NewEnvFiles() - if len(f) != 2 || err != nil { - t.Errorf("invalid files: %v %v", f, err) + t.Setenv("LOCKBOX_CONFIG_TOML", "detect") + f = config.NewConfigFiles() + if len(f) != 2 { + t.Errorf("invalid files: %v", f) } - t.Setenv("LOCKBOX_ENV", "detect") + t.Setenv("LOCKBOX_CONFIG_TOML", "detect") t.Setenv("XDG_CONFIG_HOME", "test") - f, err = config.NewEnvFiles() - if len(f) != 4 || err != nil { - t.Errorf("invalid files: %v %v", f, err) + f = config.NewConfigFiles() + if len(f) != 4 { + t.Errorf("invalid files: %v", f) } - t.Setenv("LOCKBOX_ENV", "detect") + t.Setenv("LOCKBOX_CONFIG_TOML", "detect") os.Unsetenv("HOME") - f, err = config.NewEnvFiles() - if len(f) != 2 || err != nil { - t.Errorf("invalid files: %v %v", f, err) - } - t.Setenv("LOCKBOX_ENV", "detect") - os.Unsetenv("XDG_CONFIG_HOME") - _, err = config.NewEnvFiles() - if err == nil || err.Error() != "unable to initialize default config locations" { - t.Errorf("invalid files: %v", err) + f = config.NewConfigFiles() + if len(f) != 2 { + t.Errorf("invalid files: %v", f) } } @@ -180,103 +174,6 @@ func TestEnviron(t *testing.T) { } } -func TestExpandParsed(t *testing.T) { - os.Clearenv() - t.Setenv("TEST_ABC", "1") - t.Setenv("LOCKBOX_ENV_EXPANDS", "a") - _, err := config.ExpandParsed(nil) - if err == nil || err.Error() != "invalid input variables" { - t.Errorf("invalid error: %v", err) - } - r, err := config.ExpandParsed(make(map[string]string)) - if err != nil || len(r) != 0 { - t.Errorf("invalid expand") - } - t.Setenv("LOCKBOX_ENV_EXPANDS", "a") - ins := make(map[string]string) - ins["TEST"] = "$TEST_ABC" - _, err = config.ExpandParsed(ins) - if err == nil || err.Error() != "strconv.Atoi: parsing \"a\": invalid syntax" { - t.Errorf("invalid error: %v", err) - } - ins["LOCKBOX_ENV_EXPANDS"] = "2" - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 2 || r["TEST"] != "1" { - t.Errorf("invalid expand: %v", r) - } - delete(ins, "LOCKBOX_ENV_EXPANDS") - t.Setenv("LOCKBOX_ENV_EXPANDS", "2") - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 1 || r["TEST"] != "1" { - t.Errorf("invalid expand: %v", r) - } - t.Setenv("LOCKBOX_ENV_EXPANDS", "2") - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 1 || r["TEST"] != "1" { - t.Errorf("invalid expand: %v", r) - } - t.Setenv("TEST_ABC", "$OTHER_TEST") - t.Setenv("OTHER_TEST", "$ANOTHER_TEST") - t.Setenv("ANOTHER_TEST", "2") - t.Setenv("LOCKBOX_ENV_EXPANDS", "1") - if _, err = config.ExpandParsed(ins); err == nil || err.Error() != "reached maximum expand cycle count" { - t.Errorf("invalid error: %v", err) - } - t.Setenv("LOCKBOX_ENV_EXPANDS", "2") - ins["OTHER_FIRST"] = "2" - ins["OTHER_OTHER"] = "$ANOTHER_TEST|$TEST_ABC|$OTHER_TEST" - ins["OTHER"] = "$OTHER_OTHER|$OTHER_FIRST" - t.Setenv("LOCKBOX_ENV_EXPANDS", "20") - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 4 || r["TEST"] != "2" || r["OTHER"] != "2|2|2|2" || r["OTHER_OTHER"] != "2|2|2" { - t.Errorf("invalid expand: %v", r) - } - t.Setenv("LOCKBOX_ENV_EXPANDS", "0") - delete(ins, "OTHER_FIRST") - delete(ins, "OTHER") - delete(ins, "OTHER_OTHER") - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 1 || r["TEST"] != "$TEST_ABC" { - t.Errorf("invalid expand: %v", r) - } - os.Unsetenv("LOCKBOX_ENV_EXPANDS") - delete(ins, "OTHER_FIRST") - delete(ins, "OTHER") - delete(ins, "OTHER_OTHER") - ins["LOCKBOX_ENV_EXPANDS"] = "0" - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 2 || r["TEST"] != "$TEST_ABC" { - t.Errorf("invalid expand: %v", r) - } - ins["LOCKBOX_ENV_EXPANDS"] = "5" - ins["TEST"] = "\"abc\"" - t.Setenv("LOCKBOX_ENV_QUOTED", "yes") - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 2 || r["TEST"] != "abc" { - t.Errorf("invalid expand: %v", r) - } - t.Setenv("LOCKBOX_ENV_QUOTED", "no") - ins["TEST"] = "\"abc\"" - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 2 || r["TEST"] != "\"abc\"" { - t.Errorf("invalid expand: %v", r) - } - os.Unsetenv("LOCKBOX_ENV_QUOTED") - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 2 || r["TEST"] != "abc" { - t.Errorf("invalid expand: %v", r) - } - ins["LOCKBOX_ENV_QUOTED"] = "yes" - r, err = config.ExpandParsed(ins) - if err != nil || len(r) != 3 || r["TEST"] != "abc" { - t.Errorf("invalid expand: %v", r) - } - ins["LOCKBOX_ENV_QUOTED"] = "1" - if _, err = config.ExpandParsed(ins); err == nil || err.Error() != "invalid yes/no env value for LOCKBOX_ENV_QUOTED" { - t.Errorf("invalid error: %v", err) - } -} - func TestWrap(t *testing.T) { w := config.Wrap(0, "") if w != "" { diff --git a/internal/config/toml.go b/internal/config/toml.go @@ -0,0 +1,220 @@ +package config + +import ( + "bytes" + _ "embed" + "errors" + "fmt" + "io" + "os" + "slices" + "strings" + + "github.com/BurntSushi/toml" +) + +type ( + ConfigLoader func(string) (io.Reader, error) + ShellEnv struct { + Key string + Value string + } +) + +var ( + //go:embed "config.toml" + ExampleTOML string + redirects = map[string]string{ + "HOOK_DIRECTORY": EnvHookDir.Key(), + "HOOK_ENABLED": EnvNoHooks.Key(), + "JSON_MODE": EnvJSONDataOutput.Key(), + "JSON_HASH_LENGTH": EnvHashLength.Key(), + "KEYS_FILE": EnvKeyFile.Key(), + "KEYS_MODE": EnvKeyMode.Key(), + "KEYS_KEY": envKey.Key(), + "CLIP_ENABLED": EnvNoClip.Key(), + "COLOR_ENABLED": EnvNoColor.Key(), + "PWGEN_ENABLED": EnvNoPasswordGen.Key(), + "TOTP_ENABLED": EnvNoTOTP.Key(), + "TOTP_ATTRIBUTE": EnvTOTPToken.Key(), + "ENTRY_MODTIME": EnvModTime.Key(), + } + arrayTypes = []string{ + EnvClipCopy.Key(), + EnvClipPaste.Key(), + EnvPasswordGenWordList.Key(), + envKey.Key(), + EnvTOTPColorBetween.Key(), + } + intTypes = []string{ + EnvClipMax.Key(), + EnvMaxTOTP.Key(), + EnvHashLength.Key(), + EnvPasswordGenCount.Key(), + } + boolTypes = []string{ + EnvClipOSC52.Key(), + EnvNoClip.Key(), + EnvNoColor.Key(), + EnvNoHooks.Key(), + EnvNoPasswordGen.Key(), + EnvNoTOTP.Key(), + EnvPasswordGenTitle.Key(), + EnvReadOnly.Key(), + } +) + +func LoadConfig(r io.Reader, loader ConfigLoader) ([]ShellEnv, error) { + m := make(map[string]interface{}) + if err := overlayConfig(r, true, &m, loader); err != nil { + return nil, err + } + var allowed []string + for _, k := range registeredEnv { + allowed = append(allowed, k.self().Key()) + } + m = flatten(m, "") + var res []ShellEnv + for k, v := range m { + export := strings.ToUpper(k) + if r, ok := redirects[export]; ok { + export = r + } else { + export = environmentPrefix + export + } + if !slices.Contains(allowed, export) { + return nil, fmt.Errorf("unknown key: %s (%s)", k, export) + } + value, ok := v.(string) + if !ok { + if slices.Contains(arrayTypes, export) { + array, err := parseStringArray(v) + if err != nil { + return nil, err + } + value = strings.Join(array, " ") + } else if slices.Contains(intTypes, export) { + i, ok := v.(int64) + if !ok { + return nil, fmt.Errorf("non-int64 found where expected: %v", v) + } + if i < 0 { + return nil, fmt.Errorf("%d is negative (not allowed here)", i) + } + value = fmt.Sprintf("%d", i) + } else if slices.Contains(boolTypes, export) { + switch t := v.(type) { + case bool: + if t { + value = yes + } else { + value = no + } + default: + return nil, fmt.Errorf("non-bool found where expected: %v", v) + } + } else { + return nil, fmt.Errorf("unknown field, can't determine type: %s (%v)", k, v) + } + } + value = os.Expand(value, os.Getenv) + res = append(res, ShellEnv{Key: export, Value: value}) + + } + return res, nil +} + +func overlayConfig(r io.Reader, canInclude bool, m *map[string]interface{}, loader ConfigLoader) error { + d := toml.NewDecoder(r) + if _, err := d.Decode(m); err != nil { + return err + } + res := *m + includes, ok := res["include"] + if ok { + delete(*m, "include") + including, err := parseStringArray(includes) + if err != nil { + return err + } + if len(including) > 0 { + if !canInclude { + return errors.New("nested includes not allowed") + } + for _, s := range including { + read := os.Expand(s, os.Getenv) + reader, err := loader(read) + if err != nil { + return err + } + if err := overlayConfig(reader, false, m, nil); err != nil { + return err + } + } + } + } + return nil +} + +func parseStringArray(value interface{}) ([]string, error) { + var res []string + switch t := value.(type) { + case []interface{}: + for _, item := range t { + switch s := item.(type) { + case string: + res = append(res, s) + default: + return nil, fmt.Errorf("value is not string in array: %v", item) + } + } + default: + return nil, fmt.Errorf("value is not of array type: %v", value) + } + return res, nil +} + +func flatten(m map[string]interface{}, prefix string) map[string]interface{} { + flattened := make(map[string]interface{}) + + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "_" + k + } + + switch to := v.(type) { + case map[string]interface{}: + for subKey, subVal := range flatten(to, key) { + flattened[subKey] = subVal + } + default: + flattened[key] = v + } + } + + return flattened +} + +func configLoader(path string) (io.Reader, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func LoadConfigFile(path string) error { + reader, err := configLoader(path) + if err != nil { + return err + } + env, err := LoadConfig(reader, configLoader) + if err != nil { + return err + } + for _, v := range env { + os.Setenv(v.Key, v.Value) + } + return nil +} diff --git a/internal/config/toml_test.go b/internal/config/toml_test.go @@ -0,0 +1,278 @@ +package config_test + +import ( + "errors" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/seanenck/lockbox/internal/config" +) + +func TestLoadIncludes(t *testing.T) { + defer os.Clearenv() + t.Setenv("TEST", "xyz") + data := `include = ["$TEST/abc"]` + r := strings.NewReader(data) + if _, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + if p == "xyz/abc" { + return strings.NewReader("include = [\"aaa\"]"), nil + } else { + return nil, errors.New("invalid path") + } + }); err == nil || err.Error() != "nested includes not allowed" { + t.Errorf("invalid error: %v", err) + } + data = `include = ["abc"]` + r = strings.NewReader(data) + if _, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + if p == "xyz/abc" { + return strings.NewReader("include = [\"aaa\"]"), nil + } else { + return nil, errors.New("invalid path") + } + }); err == nil || err.Error() != "invalid path" { + t.Errorf("invalid error: %v", err) + } + data = `include = 1` + r = strings.NewReader(data) + if _, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + if p == "xyz/abc" { + return strings.NewReader("include = [\"aaa\"]"), nil + } else { + return nil, errors.New("invalid path") + } + }); err == nil || err.Error() != "value is not of array type: 1" { + t.Errorf("invalid error: %v", err) + } + data = `include = [1]` + r = strings.NewReader(data) + if _, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + if p == "xyz/abc" { + return strings.NewReader("include = [\"aaa\"]"), nil + } else { + return nil, errors.New("invalid path") + } + }); err == nil || err.Error() != "value is not string in array: 1" { + t.Errorf("invalid error: %v", err) + } + data = `include = ["$TEST/abc"] +store="xyz" +` + r = strings.NewReader(data) + env, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + if p == "xyz/abc" { + return strings.NewReader("store = 'abc'"), nil + } else { + return nil, errors.New("invalid path") + } + }) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if len(env) != 1 || env[0].Key != "LOCKBOX_STORE" || env[0].Value != "abc" { + t.Errorf("invalid object: %v", env) + } +} + +func TestArrayLoad(t *testing.T) { + defer os.Clearenv() + t.Setenv("TEST", "abc") + data := `store="xyz" +[clip] +copy = ["'xyz/$TEST'", "s", 1] +` + r := strings.NewReader(data) + _, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err == nil || err.Error() != "value is not string in array: 1" { + t.Errorf("invalid error: %v", err) + } + data = `include = [] +store="xyz" +[clip] +copy = ["'xyz/$TEST'", "s"] +` + r = strings.NewReader(data) + env, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err != nil { + t.Errorf("invalid error: %v", err) + } + slices.SortFunc(env, func(x, y config.ShellEnv) int { + return strings.Compare(x.Key, y.Key) + }) + if len(env) != 2 || env[1].Key != "LOCKBOX_STORE" || env[1].Value != "xyz" || env[0].Key != "LOCKBOX_CLIP_COPY" || env[0].Value != "'xyz/abc' s" { + t.Errorf("invalid object: %v", env) + } + data = `include = [] +store="xyz" +[clip] +copy = "'xyz/$TEST' s" +` + r = strings.NewReader(data) + env, err = config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err != nil { + t.Errorf("invalid error: %v", err) + } + slices.SortFunc(env, func(x, y config.ShellEnv) int { + return strings.Compare(x.Key, y.Key) + }) + if len(env) != 2 || env[1].Key != "LOCKBOX_STORE" || env[1].Value != "xyz" || env[0].Key != "LOCKBOX_CLIP_COPY" || env[0].Value != "'xyz/abc' s" { + t.Errorf("invalid object: %v", env) + } +} + +func TestRedirect(t *testing.T) { + data := `include = [] +[hook] +directory = "xyz" +` + r := strings.NewReader(data) + env, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if len(env) != 1 || env[0].Key != "LOCKBOX_HOOKDIR" || env[0].Value != "xyz" { + t.Errorf("invalid object: %v", env) + } +} + +func TestReadInt(t *testing.T) { + data := ` +[clip] +max = true +` + r := strings.NewReader(data) + _, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err == nil || err.Error() != "non-int64 found where expected: true" { + t.Errorf("invalid error: %v", err) + } + data = `include = [] +[clip] +max = 1 +` + r = strings.NewReader(data) + env, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if len(env) != 1 || env[0].Key != "LOCKBOX_CLIP_MAX" || env[0].Value != "1" { + t.Errorf("invalid object: %v", env) + } + data = `include = [] +[clip] +max = -1 +` + r = strings.NewReader(data) + env, err = config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err == nil || err.Error() != "-1 is negative (not allowed here)" { + t.Errorf("invalid error: %v", err) + } +} + +func TestReadBool(t *testing.T) { + defer os.Clearenv() + t.Setenv("TEST", "abc") + data := ` +[totp] +enabled = 1 +` + r := strings.NewReader(data) + _, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err == nil || err.Error() != "non-bool found where expected: 1" { + t.Errorf("invalid error: %v", err) + } + data = `include = [] +[totp] +enabled = true +` + r = strings.NewReader(data) + env, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if len(env) != 1 || env[0].Key != "LOCKBOX_NOTOTP" || env[0].Value != "yes" { + t.Errorf("invalid object: %v", env) + } + data = `include = [] +[totp] +enabled = false +` + r = strings.NewReader(data) + env, err = config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err != nil { + t.Errorf("invalid error: %v", err) + } + if len(env) != 1 || env[0].Key != "LOCKBOX_NOTOTP" || env[0].Value != "no" { + t.Errorf("invalid object: %v", env) + } +} + +func TestBadValues(t *testing.T) { + defer os.Clearenv() + t.Setenv("TEST", "abc") + data := ` +[totsp] +enabled = "false" +` + r := strings.NewReader(data) + _, err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err == nil || err.Error() != "unknown key: totsp_enabled (LOCKBOX_TOTSP_ENABLED)" { + t.Errorf("invalid error: %v", err) + } + data = `include = [] +[totp] +format = -1 +` + r = strings.NewReader(data) + _, err = config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }) + if err == nil || err.Error() != "unknown field, can't determine type: totp_format (-1)" { + t.Errorf("invalid error: %v", err) + } +} + +func TestLoadFile(t *testing.T) { + os.Mkdir("testdata", 0o755) + // defer os.RemoveAll("testdata") + defer os.Clearenv() + file := filepath.Join("testdata", "config.toml") + os.WriteFile(file, []byte(config.ExampleTOML), 0o644) + if err := config.LoadConfigFile(file); err != nil { + t.Errorf("invalid error: %v", err) + } + count := 0 + for _, item := range os.Environ() { + if strings.HasPrefix(item, "LOCKBOX_") { + count++ + } + } + if count != 29 { + t.Errorf("invalid environment after load: %d", count) + } +} diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -271,14 +271,15 @@ and '%s' allows for multiple windows.`, colorWindowSpan, colorWindowDelimiter), cat: totpCategory, desc: "Override the otpauth url used to store totp tokens. It must have ONE format string ('%s') to insert the totp base code.", }, fxn: formatterTOTP, allowed: "otpauth//url/%s/args..."}) - // EnvConfig is the location of the config file to read environment variables from + // EnvConfig is the location of the config file to read EnvConfig = environmentRegister( EnvironmentString{ environmentDefault: newDefaultedEnvironment(detectEnvironment, environmentBase{ - subKey: "ENV", - desc: fmt.Sprintf(`Allows setting a specific file of environment variables for lockbox to read and use as -configuration values (an '.env' file). The keyword '%s' will disable this functionality and the keyword '%s' will + subKey: "CONFIG_TOML", + desc: fmt.Sprintf(`Allows setting a specific toml file to read and load into the environment. + +The keyword '%s' will disable this functionality and the keyword '%s' will search for a file in the following paths in XDG_CONFIG_HOME (%s) or from the user's HOME (%s). Matches the first file found. @@ -313,26 +314,6 @@ Set to '%s' to ignore the set key value`, noKeyMode, IgnoreKeyMode), allowed: []string{commandArgsExample, "password"}, canDefault: false, }) - envConfigQuoted = environmentRegister( - EnvironmentBool{ - environmentDefault: newDefaultedEnvironment(true, - environmentBase{ - subKey: EnvConfig.subKey + "_QUOTED", - desc: "Enables removing prefix/suffix quotes from shell environment config settings\nwhen loaded through configuration file.", - }), - }) - envConfigExpands = environmentRegister( - EnvironmentInt{ - environmentDefault: newDefaultedEnvironment(20, - environmentBase{ - subKey: EnvConfig.subKey + "_EXPANDS", - desc: `The maximum number of times to expand the input env to resolve variables (set to 0 to disable expansion). - -This value can NOT be an expansion itself.`, - }), - shortDesc: "max expands", - allowZero: true, - }) // EnvPasswordGenCount is the number of words that will be selected for password generation EnvPasswordGenCount = environmentRegister( EnvironmentInt{ diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -105,7 +105,7 @@ func TestListVariables(t *testing.T) { known[trim] = struct{}{} } l := len(known) - if l != 34 { + if l != 32 { t.Errorf("invalid env count, outdated? %d", l) } } @@ -240,7 +240,7 @@ func TestEnvironDefinitions(t *testing.T) { if !strings.HasPrefix(env, "LOCKBOX_") { t.Errorf("invalid env var: %s", env) } - if env == "LOCKBOX_ENV" { + if env == "LOCKBOX_CONFIG_TOML" { continue } t.Setenv(env, "test") diff --git a/internal/platform/os.go b/internal/platform/os.go @@ -9,9 +9,6 @@ import ( "os" "strings" "syscall" - - "github.com/seanenck/lockbox/internal/config" - "mvdan.cc/sh/v3/expand" ) func termEcho(on bool) { @@ -141,39 +138,3 @@ func PathExists(file string) bool { } return true } - -// LoadEnvConfigs load environment settings from configs -func LoadEnvConfigs(paths ...string) error { - for _, useEnv := range paths { - if !PathExists(useEnv) { - continue - } - b, err := os.ReadFile(useEnv) - if err != nil { - return err - } - env := expand.ListEnviron(strings.Split(string(b), "\n")...) - found := make(map[string]string) - env.Each(func(name string, vr expand.Variable) bool { - found[name] = vr.String() - return true - }) - result, err := config.ExpandParsed(found) - if err != nil { - return err - } - for k, v := range result { - ok, err := config.IsUnset(k, v) - if err != nil { - return err - } - if !ok { - if err := os.Setenv(k, v); err != nil { - return err - } - } - } - break - } - return nil -} diff --git a/internal/platform/os_test.go b/internal/platform/os_test.go @@ -1,10 +1,8 @@ package platform_test import ( - "fmt" "os" "path/filepath" - "strings" "testing" "github.com/seanenck/lockbox/internal/platform" @@ -21,58 +19,3 @@ func TestPathExist(t *testing.T) { t.Error("test dir SHOULD exist") } } - -func TestLoadEnvConfigs(t *testing.T) { - os.Clearenv() - files := []string{filepath.Join("testdata", "xyz"), filepath.Join("testdata", "abc")} - if err := platform.LoadEnvConfigs(files...); err != nil { - t.Errorf("unexpected error: %v", err) - } - cfg := files[1] - os.WriteFile(cfg, []byte(` -TEST_X=1 -TEST_Y=2 -TEST_Z="1 -TEST11=1" -TEST_3="abc $HOME $X $TEST_X"`), 0o644) - if err := platform.LoadEnvConfigs(files...); err != nil { - t.Errorf("unexpected error: %v", err) - } - env := fmt.Sprintf("%v", os.Environ()) - verify := func(expects []string) { - for _, e := range expects { - if !strings.Contains(env, e) { - t.Errorf("invalid env: %s (missing '%s')", env, e) - } - } - } - verify([]string{"TEST_X=1", "TEST_Y=2", "TEST_3=abc 1", "TEST_Z=\"1", "TEST11=1\""}) - t.Setenv("HOME", "a123") - if err := platform.LoadEnvConfigs(files...); err != nil { - t.Errorf("unexpected error: %v", err) - } - env = fmt.Sprintf("%v", os.Environ()) - verify([]string{"TEST_X=1", "TEST_Y=2", "TEST_3=abc a123 1", "TEST_Z=\"1", "TEST11=1\""}) - t.Setenv("XYZ", "xyz") - t.Setenv("HOME", "$TEST4") - t.Setenv("TEST4", "$XYZ") - if err := platform.LoadEnvConfigs(files...); err != nil { - t.Errorf("unexpected error: %v", err) - } - env = fmt.Sprintf("%v", os.Environ()) - verify([]string{"TEST_X=1", "TEST_Y=2", "TEST_3=abc xyz 1", "TEST_Z=\"1", "TEST11=1\""}) - final := filepath.Join("testdata", "zzz") - files = append(files, final) - t.Setenv("TEST4", "a") - os.WriteFile(final, []byte(` -TEST_X=1 -TEST_Y=2 -TEST_Z="1 -TEST11=2" -TEST_3="abc $HOME $X $TEST_X"`), 0o644) - if err := platform.LoadEnvConfigs(files...); err != nil { - t.Errorf("unexpected error: %v", err) - } - env = fmt.Sprintf("%v", os.Environ()) - verify([]string{"TEST_X=1", "TEST_Y=2", "TEST_3=abc a 1", "TEST_Z=\"1", "TEST11=1\""}) -} diff --git a/justfile b/justfile @@ -11,10 +11,10 @@ build: go build {{goflags}} -ldflags "{{ldflags}} -X main.version={{version}}" -o "{{object}}" cmd/main.go unittest: - LOCKBOX_ENV=none go test ./... + LOCKBOX_CONFIG_TOML=none go test ./... check: unittest build - cd tests && LOCKBOX_ENV=none ./run.sh + cd tests && LOCKBOX_CONFIG_TOML=none ./run.sh clean: rm -f "{{object}}" diff --git a/tests/run.sh b/tests/run.sh @@ -1,7 +1,7 @@ #!/bin/sh LB_BINARY=../target/lb DATA="testdata/$1" -ENV="$DATA/env" +TOML="$DATA/config.toml" CLIP_WAIT=1 CLIP_TRIES=3 CLIP_COPY="$DATA/clip.copy" @@ -26,7 +26,7 @@ if [ -z "$1" ]; then fi _unset -export LOCKBOX_ENV="none" +export LOCKBOX_CONFIG_TOML="none" if [ ! -x "${LB_BINARY}" ]; then echo "binary missing?" exit 1 @@ -219,15 +219,29 @@ printf "%-10s ... " "$1" export LOCKBOX_KEYMODE="$OLDMODE" # configuration { - echo "PLAINTEXT=text" - # shellcheck disable=SC2016 - env | grep '^LOCKBOX' | sed 's/plaintext/$LOCKBOX_FAKE_TEST$PLAINTEXT/g' - } > "$ENV" + cat << EOF +store = "$LOCKBOX_STORE" + +[clip] +copy = [$(echo "$LOCKBOX_CLIP_COPY" | sed 's/ /", "/g;s/^/"/g;s/$/"/g')] +copy = [$(echo "$LOCKBOX_CLIP_PASTE" | sed 's/ /", "/g;s/^/"/g;s/$/"/g')] +max = $LOCKBOX_CLIP_MAX + +[json] +mode = "$LOCKBOX_JSON_DATA" +hash_length = $LOCKBOX_JSON_DATA_HASH_LENGTH + +[keys] +file = "$LOCKBOX_KEYFILE" +mode = "$LOCKBOX_KEYMODE" +key = "$LOCKBOX_KEY" +EOF + } > "$TOML" _unset export LOCKBOX_FAKE_TEST=plain - export LOCKBOX_ENV="none" + export LOCKBOX_CONFIG_TOML="none" ${LB_BINARY} ls - export LOCKBOX_ENV="$ENV" + export LOCKBOX_CONFIG_TOML="$TOML" ${LB_BINARY} ls } 2>&1 | \ sed 's/"modtime": "[0-9].*$/"modtime": "XXXX-XX-XX",/g' | \