lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 6137c51463c9aa97d07cd7e53e2c499b5450e213
parent a2259cdf6a0abfc84c6f2330b9caecec1f22cba2
Author: Sean Enck <sean@ttypty.com>
Date:   Sun,  8 Dec 2024 11:21:18 -0500

eliminate aggressive os.Expand, allow templates and other strings to behave normally without env involved now

Diffstat:
Minternal/app/pwgen.go | 14+++-----------
Minternal/app/pwgen_test.go | 2+-
Minternal/config/core.go | 26++++++++++++--------------
Minternal/config/env.go | 23++++++++++++-----------
Minternal/config/toml.go | 15++++++++++-----
Minternal/config/toml_test.go | 36++++++++++++++++++++++++++++++++----
Minternal/config/vars.go | 8++++++--
Minternal/util/reflect.go | 13+++++++++++++
Minternal/util/text.go | 19+++++++++++++++++++
Minternal/util/text_test.go | 7+++++++
Mtests/main.go | 2+-
11 files changed, 116 insertions(+), 49 deletions(-)

diff --git a/internal/app/pwgen.go b/internal/app/pwgen.go @@ -12,6 +12,7 @@ import ( "text/template" "github.com/seanenck/lockbox/internal/config" + "github.com/seanenck/lockbox/internal/util" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -30,7 +31,6 @@ func GeneratePassword(cmd CommandOptions) error { return fmt.Errorf("word count must be >= 1") } tmplString := config.EnvPasswordGenTemplate.Get() - tmplString = strings.ReplaceAll(tmplString, config.TemplateVariable, "$") wordList := config.EnvPasswordGenWordList.Get() if len(wordList) == 0 { return errors.New("word list command must set") @@ -82,25 +82,17 @@ func GeneratePassword(cmd CommandOptions) error { } choices = append(choices, use) } - type position struct { - Start int - End int - } - type word struct { - Text string - Position position - } found := len(choices) if found == 0 { return errors.New("no sources given") } - var selected []word + var selected []util.Word var cnt int64 totalLength := 0 for cnt < length { choice := choices[rand.Intn(found)] textLength := len(choice) - selected = append(selected, word{choice, position{totalLength, totalLength + textLength}}) + selected = append(selected, util.Word{Text: choice, Position: util.Position{Start: totalLength, End: totalLength + textLength}}) totalLength += textLength cnt++ } diff --git a/internal/app/pwgen_test.go b/internal/app/pwgen_test.go @@ -90,7 +90,7 @@ func TestGenerate(t *testing.T) { // NOTE: this allows templating below in golang store.SetString("LOCKBOX_PWGEN_TEMPLATE", "{{range $idx, $val := .}}{{if lt $idx 5}}-{{end}}{{ $val.Text }}{{ $val.Position.Start }}{{ $val.Position.End }}{{end}}") testPasswordGen(t, "-a01-a12-a23-a34") - store.SetString("LOCKBOX_PWGEN_TEMPLATE", "{{range [%]idx, [%]val := .}}{{if lt [%]idx 5}}-{{end}}{{ [%]val.Text }}{{end}}") + store.SetString("LOCKBOX_PWGEN_TEMPLATE", "{{range $idx, $val := .}}{{if lt $idx 5}}-{{end}}{{ $val.Text }}{{end}}") testPasswordGen(t, "-a-a-a-a") store.Clear() store.SetBool("LOCKBOX_PWGEN_TITLE", true) diff --git a/internal/config/core.go b/internal/config/core.go @@ -16,19 +16,17 @@ import ( const ( // sub categories - clipCategory keyCategory = "CLIP_" - totpCategory keyCategory = "TOTP_" - genCategory keyCategory = "PWGEN_" - jsonCategory keyCategory = "JSON_" - credsCategory keyCategory = "CREDENTIALS_" - defaultCategory keyCategory = "DEFAULTS_" - hookCategory keyCategory = "HOOKS_" - // TemplateVariable is used to handle '$' in shell vars (due to expansion) - TemplateVariable = "[%]" - environmentPrefix = "LOCKBOX_" - commandArgsExample = "[cmd args...]" - fileExample = "<file>" - requiredKeyOrKeyFile = "a key, a key file, or both must be set" + clipCategory keyCategory = "CLIP_" + totpCategory keyCategory = "TOTP_" + genCategory keyCategory = "PWGEN_" + jsonCategory keyCategory = "JSON_" + credsCategory keyCategory = "CREDENTIALS_" + defaultCategory keyCategory = "DEFAULTS_" + hookCategory keyCategory = "HOOKS_" + environmentPrefix = "LOCKBOX_" + commandArgsExample = "[cmd args...]" + fileExample = "<file>" + requiredKeyOrKeyFile = "a key, a key file, or both must be set" // ModTimeFormat is the expected modtime format ModTimeFormat = time.RFC3339 exampleColorWindow = "start" + util.TimeWindowSpan + "end" @@ -66,7 +64,7 @@ type ( printer interface { values() (string, []string) self() environmentBase - toml() (tomlType, string) + toml() (tomlType, string, bool) } ) diff --git a/internal/config/env.go b/internal/config/env.go @@ -35,6 +35,7 @@ type ( canDefault bool allowed []string isArray bool + expand bool } // EnvironmentCommand are settings that are parsed as shell commands EnvironmentCommand struct { @@ -138,25 +139,25 @@ func (e EnvironmentCommand) values() (string, []string) { return detectedValue, []string{commandArgsExample} } -func (e EnvironmentInt) toml() (tomlType, string) { - return tomlInt, "0" +func (e EnvironmentInt) toml() (tomlType, string, bool) { + return tomlInt, "0", false } -func (e EnvironmentBool) toml() (tomlType, string) { - return tomlBool, "true" +func (e EnvironmentBool) toml() (tomlType, string, bool) { + return tomlBool, YesValue, false } -func (e EnvironmentString) toml() (tomlType, string) { +func (e EnvironmentString) toml() (tomlType, string, bool) { if e.isArray { - return tomlArray, "[]" + return tomlArray, "[]", e.expand } - return tomlString, "\"\"" + return tomlString, "\"\"", e.expand } -func (e EnvironmentCommand) toml() (tomlType, string) { - return tomlArray, "[]" +func (e EnvironmentCommand) toml() (tomlType, string, bool) { + return tomlArray, "[]", true } -func (e EnvironmentFormatter) toml() (tomlType, string) { - return tomlString, "\"\"" +func (e EnvironmentFormatter) toml() (tomlType, string, bool) { + return tomlString, "\"\"", false } diff --git a/internal/config/toml.go b/internal/config/toml.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "github.com/BurntSushi/toml" @@ -53,7 +54,7 @@ func DefaultTOML() (string, error) { default: sub = strings.Join(parts[1:], "_") } - _, field := item.toml() + _, field, _ := item.toml() text, err := generateDetailText(item) if err != nil { return "", err @@ -112,7 +113,7 @@ func generateDetailText(data printer) (string, error) { if r != "" { requirement = r } - t, _ := data.toml() + t, _, expands := data.toml() var text []string for _, line := range []string{ fmt.Sprintf("description:\n%s\n", description), @@ -120,6 +121,7 @@ func generateDetailText(data printer) (string, error) { fmt.Sprintf("option: %s", strings.Join(allow, "|")), fmt.Sprintf("%s name: %s", commands.Env, key), fmt.Sprintf("default: %s", value), + fmt.Sprintf("expands: %s", strconv.FormatBool(expands)), fmt.Sprintf("type: %s", t), "", "NOTE: the following value is NOT a default, it is an empty TOML placeholder", @@ -149,10 +151,10 @@ func LoadConfig(r io.Reader, loader Loader) error { if !ok { return fmt.Errorf("unknown key: %s (%s)", k, export) } - isType, _ := env.toml() + isType, _, expand := env.toml() switch isType { case tomlArray: - array, err := parseStringArray(v, true) + array, err := parseStringArray(v, expand) if err != nil { return err } @@ -178,7 +180,10 @@ func LoadConfig(r io.Reader, loader Loader) error { if !ok { return fmt.Errorf("non-string found where expected: %v", v) } - store.SetString(export, os.Expand(s, os.Getenv)) + if expand { + s = os.Expand(s, os.Getenv) + } + store.SetString(export, s) default: return fmt.Errorf("unknown field, can't determine type: %s (%v)", k, v) } diff --git a/internal/config/toml_test.go b/internal/config/toml_test.go @@ -189,8 +189,6 @@ timeout = -1 func TestReadBool(t *testing.T) { store.Clear() - defer os.Clearenv() - t.Setenv("TEST", "abc") data := ` [totp] enabled = 1 @@ -240,8 +238,6 @@ enabled = false func TestBadValues(t *testing.T) { store.Clear() - defer os.Clearenv() - t.Setenv("TEST", "abc") data := ` [totsp] enabled = "false" @@ -283,3 +279,35 @@ func TestDefaultTOMLToLoadFile(t *testing.T) { t.Errorf("invalid environment after load") } } + +func TestExpands(t *testing.T) { + store.Clear() + t.Setenv("TEST", "1") + data := `include = [] +store = "$TEST" +clip.copy_command = ["$TEST", "$TEST"] +[totp] +otp_format = "$TEST" +` + r := strings.NewReader(data) + if err := config.LoadConfig(r, func(p string) (io.Reader, error) { + return nil, nil + }); err != nil { + t.Errorf("invalid error: %v", err) + } + if len(store.List()) != 3 { + t.Errorf("invalid store") + } + val, ok := store.GetString("LOCKBOX_TOTP_OTP_FORMAT") + if val != "$TEST" || !ok { + t.Errorf("invalid object: %v", val) + } + val, ok = store.GetString("LOCKBOX_STORE") + if val != "1" || !ok { + t.Errorf("invalid object: %v", val) + } + a, ok := store.GetArray("LOCKBOX_CLIP_COPY_COMMAND") + if fmt.Sprintf("%v", a) != "[1 1]" || !ok { + t.Errorf("invalid object: %v", a) + } +} diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -139,6 +139,7 @@ var ( // EnvStore is the location of the keepass file/store EnvStore = environmentRegister( EnvironmentString{ + expand: true, environmentDefault: newDefaultedEnvironment("", environmentBase{ subKey: "STORE", @@ -151,6 +152,7 @@ var ( // EnvHookDir is the directory of hooks to execute EnvHookDir = environmentRegister( EnvironmentString{ + expand: true, environmentDefault: newDefaultedEnvironment("", environmentBase{ cat: hookCategory, @@ -190,6 +192,7 @@ and '%s' allows for multiple windows.`, util.TimeWindowSpan, util.TimeWindowDeli // EnvKeyFile is an keyfile for the database EnvKeyFile = environmentRegister( EnvironmentString{ + expand: true, environmentDefault: newDefaultedEnvironment("", environmentBase{ cat: credsCategory, @@ -246,6 +249,7 @@ Set to '%s' to ignore the set key value`, noKeyMode, IgnoreKeyMode), }) envPassword = environmentRegister( EnvironmentString{ + expand: true, environmentDefault: newDefaultedEnvironment("", environmentBase{ cat: credsCategory, @@ -284,11 +288,11 @@ Set to '%s' to ignore the set key value`, noKeyMode, IgnoreKeyMode), // EnvPasswordGenTemplate is the output template for controlling how output words are placed together EnvPasswordGenTemplate = environmentRegister( EnvironmentString{ - environmentDefault: newDefaultedEnvironment(fmt.Sprintf("{{range %si, %sval := .}}{{if %si}}-{{end}}{{%sval.Text}}{{end}}", TemplateVariable, TemplateVariable, TemplateVariable, TemplateVariable), + environmentDefault: newDefaultedEnvironment("{{range $i, $val := .}}{{if $i}}-{{end}}{{$val.Text}}{{end}}", environmentBase{ subKey: "TEMPLATE", cat: genCategory, - desc: fmt.Sprintf("The go text template to use to format the chosen words into a password (use '%s' to include a '$' to avoid shell expansion issues). Available fields: Text, Position.Start, and Position.End.", TemplateVariable), + desc: fmt.Sprintf("The go text template to use to format the chosen words into a password. Available fields: %s.", util.TextPositionFields()), }), allowed: []string{"<go template>"}, canDefault: true, diff --git a/internal/util/reflect.go b/internal/util/reflect.go @@ -17,3 +17,16 @@ func ListFields(p any) []string { sort.Strings(vals) return vals } + +func readNested(v reflect.Type, root string) []string { + var fields []string + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + if field.Type.Kind() == reflect.Struct { + fields = append(fields, readNested(field.Type, fmt.Sprintf("%s.", field.Name))...) + } else { + fields = append(fields, fmt.Sprintf("%s%s", root, field.Name)) + } + } + return fields +} diff --git a/internal/util/text.go b/internal/util/text.go @@ -3,9 +3,23 @@ package util import ( "bytes" "fmt" + "reflect" "strings" ) +type ( + // Position is the start/end of a word in a greater set + Position struct { + Start int + End int + } + // Word is the text and position in a greater position + Word struct { + Text string + Position Position + } +) + // TextWrap performs simple block text word wrapping func TextWrap(indent uint, in string) string { var sections []string @@ -59,3 +73,8 @@ func wrap(in string, maxLength int) string { } return strings.Join(lines, "\n") } + +// TextPositionFields is the displayable set of templated fields +func TextPositionFields() string { + return strings.Join(readNested(reflect.TypeOf(Word{}), ""), ", ") +} diff --git a/internal/util/text_test.go b/internal/util/text_test.go @@ -24,3 +24,10 @@ func TestWrap(t *testing.T) { t.Errorf("invalid wrap: %s", w) } } + +func TestTextFields(t *testing.T) { + v := util.TextPositionFields() + if v != "Text, Position.Start, Position.End" { + t.Errorf("unexpected fields: %s", v) + } +} diff --git a/tests/main.go b/tests/main.go @@ -323,7 +323,7 @@ func test(profile string) error { c["pwgen.word_count"] = "1" r.writeConfig(c) r.run("", "pwgen") - c["pwgen.template"] = "\"{{range [%]idx, [%]val := .}}{{if lt [%]val.Position.End 5}}{{ [%]val.Text }}{{end}}{{end}}\"" + c["pwgen.template"] = "\"{{range $idx, $val := .}}{{if lt $val.Position.End 5}}{{ $val.Text }}{{end}}{{end}}\"" c["pwgen.characters"] = c.quoteString("b") c["pwgen.word_count"] = "2" c["pwgen.title"] = "false"