lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 5850247f1d958b7e9b21e2a3f2f451181df88773
parent 66e980f0b4bc7095b24ae74614e460aef234961f
Author: Sean Enck <sean@ttypty.com>
Date:   Sat, 20 Apr 2024 08:16:39 -0400

completions should detect values

Diffstat:
Minternal/app/completions.go | 118++++++++++++++++++++++++++-----------------------------------------------------
Minternal/app/completions_test.go | 73++++++++++++-------------------------------------------------------------
Minternal/app/core.go | 29+++++++++++++----------------
Minternal/app/core_test.go | 4++--
Minternal/app/doc/completions.txt | 9++++-----
Minternal/app/doc/fish.sh | 12++++--------
Minternal/app/doc/shell.sh | 25++++++++++---------------
Minternal/app/info.go | 8++------
Minternal/app/info_test.go | 16++++------------
Minternal/config/core.go | 2++
Minternal/config/vars.go | 34++++++++++++++++++++--------------
Minternal/config/vars_test.go | 4++++
12 files changed, 116 insertions(+), 218 deletions(-)

diff --git a/internal/app/completions.go b/internal/app/completions.go @@ -3,7 +3,6 @@ package app import ( "bytes" - "errors" "fmt" "slices" "strings" @@ -27,32 +26,28 @@ type ( DoList string Executable string JSONCommand string + DefaultCompletion string HelpCommand string HelpAdvancedCommand string - HelpShellCommand string Profiles []Profile - DefaultProfile Profile Shell string CompletionEnv string + IsYes string + DefaultProfile Profile } // Profile is a completion profile Profile struct { - Name string - CanClip bool - CanTOTP bool - CanList bool - ReadOnly bool - IsDefault bool - env []string + Name string + CanClip bool + CanTOTP bool + CanList bool + ReadOnly bool + IsDefault bool + Conditional string } ) -// Env will get the environment settable value to use this profile -func (p Profile) Env() string { - return fmt.Sprintf("%s=%s", config.EnvironmentCompletionKey, p.Display()) -} - // Options will list the profile options func (p Profile) Options() []string { opts := []string{EnvCommand, HelpCommand, ListCommand, ShowCommand, VersionCommand, JSONCommand} @@ -68,11 +63,6 @@ func (p Profile) Options() []string { return opts } -// Display is the profile display name -func (p Profile) Display() string { - return strings.Join(strings.Split(strings.ToUpper(p.Name), "-")[1:], "-") -} - // TOTPSubCommands are the list of sub commands for TOTP within the profile func (p Profile) TOTPSubCommands() []string { totp := []string{TOTPMinimalCommand, TOTPOnceCommand, TOTPShowCommand} @@ -85,11 +75,10 @@ func (p Profile) TOTPSubCommands() []string { return totp } -func loadProfiles(exe string, canFilter bool) []Profile { +func loadProfiles(exe string) []Profile { profiles := config.LoadCompletionProfiles() - filter := config.EnvCompletion.Get() - hasFilter := filter != "" && canFilter - var res []Profile + conditionals := make(map[int][]Profile) + maxCount := 0 for _, p := range profiles { name := p.Name if p.Default { @@ -101,54 +90,31 @@ func loadProfiles(exe string, canFilter bool) []Profile { n.CanTOTP = p.TOTP n.ReadOnly = !p.Write n.IsDefault = p.Default - n.env = p.Env - if hasFilter { - skipped := false - if p.Default { - skipped = filter != "DEFAULT" - } else { - if filter != n.Display() { - skipped = true - } - } - if skipped { - continue - } + var sub []string + for _, e := range p.Env { + sub = append(sub, fmt.Sprintf("[ %s ]", e)) } - res = append(res, n) + n.Conditional = strings.Join(sub, " && ") + count := len(p.Env) + val := conditionals[count] + conditionals[count] = append(val, n) + if count > maxCount { + maxCount = count + } + } + var res []Profile + for maxCount >= 0 { + val, ok := conditionals[maxCount] + if ok { + res = append(res, val...) + } + maxCount-- } return res } // GenerateCompletions handles creating shell completion outputs -func GenerateCompletions(completionType string, isHelp bool, exe string) ([]string, error) { - if isHelp { - h := []string{"completions are available for:"} - for _, s := range completionTypes { - h = append(h, fmt.Sprintf(" - %s", s)) - } - h = append(h, "") - for _, p := range loadProfiles(exe, false) { - if p.IsDefault { - continue - } - text := fmt.Sprintf("when %s\n - filtered completions\n - useful when:\n", p.Env()) - for idx, e := range p.env { - if idx > 0 { - text = fmt.Sprintf("%s and\n", text) - } - text = fmt.Sprintf("%s %s\n", text, e) - } - h = append(h, text) - } - h = append(h, strings.TrimSpace(fmt.Sprintf(` -%s is not set -unset %s -when %s=<unknown> - - default completions -`, config.EnvironmentCompletionKey, config.EnvironmentCompletionKey, config.EnvironmentCompletionKey))) - return h, nil - } +func GenerateCompletions(completionType, exe string) ([]string, error) { if !slices.Contains(completionTypes, completionType) { return nil, fmt.Errorf("unknown completion request: %s", completionType) } @@ -167,7 +133,8 @@ when %s=<unknown> MoveCommand: MoveCommand, DoList: fmt.Sprintf("%s %s", exe, ListCommand), DoTOTPList: fmt.Sprintf("%s %s %s", exe, TOTPCommand, TOTPListCommand), - CompletionEnv: fmt.Sprintf("$%s", config.EnvironmentCompletionKey), + DefaultCompletion: fmt.Sprintf("$%s", config.EnvDefaultCompletionKey), + IsYes: config.YesValue, } using, err := readDoc(fmt.Sprintf("%s.sh", completionType)) @@ -178,18 +145,11 @@ when %s=<unknown> if err != nil { return nil, err } - c.Profiles = loadProfiles(exe, true) - switch len(c.Profiles) { - case 0: - return nil, errors.New("no profiles loaded, invalid environment setting?") - case 1: - c.DefaultProfile = c.Profiles[0] - default: - for _, p := range c.Profiles { - if p.IsDefault { - c.DefaultProfile = p - break - } + c.Profiles = loadProfiles(exe) + for _, p := range c.Profiles { + if p.IsDefault { + c.DefaultProfile = p + break } } shell, err := templateScript(shellScript, c) diff --git a/internal/app/completions_test.go b/internal/app/completions_test.go @@ -1,81 +1,32 @@ package app_test import ( - "fmt" - "os" + "strings" "testing" "github.com/enckse/lockbox/internal/app" ) func TestCompletions(t *testing.T) { - testCompletion(t, "bash") - testCompletion(t, "zsh") - testCompletion(t, "fish") - if _, err := app.GenerateCompletions("invalid", false, "lb"); err.Error() != "unknown completion request: invalid" { - t.Errorf("invalid error: %v", err) + for k, v := range map[string]string{ + "zsh": "typeset -A opt_args", + "fish": "set -l commands", + "bash": "local cur opts", + } { + testCompletion(t, k, v) } } -func testCompletion(t *testing.T, completionMode string) { - v, err := app.GenerateCompletions(completionMode, true, "lb") - if err != nil { - t.Errorf("invalid error: %v", err) - } - if len(v) < 2 { - t.Errorf("invalid result") - } - defer os.Clearenv() - os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "A") - o, err := app.GenerateCompletions(completionMode, true, "lb") - if err != nil { - t.Errorf("invalid error: %v", err) - } - if len(o) < 2 || len(o) != len(v) { - t.Errorf("invalid result") - } - os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "") - v, err = app.GenerateCompletions(completionMode, false, "lb") +func testCompletion(t *testing.T, completionMode, need string) { + v, err := app.GenerateCompletions(completionMode, "lb") if err != nil { t.Errorf("invalid error: %v", err) } if len(v) != 1 { - t.Errorf("invalid result") - } - os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "ZZZ") - _, err = app.GenerateCompletions(completionMode, false, "lb") - if err == nil || err.Error() != "no profiles loaded, invalid environment setting?" { - t.Errorf("invalid error: %v", err) - } - os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "NOCLIP-READONLY") - n, err := app.GenerateCompletions(completionMode, false, "lb") - if err != nil { - t.Errorf("invalid error: %v", err) - } - if fmt.Sprintf("%v", n) == fmt.Sprintf("%v", v) { - t.Errorf("invalid result, should filter") + t.Errorf("invalid result: %v", v) } - os.Setenv("LOCKBOX_COMPLETION_FUNCTION", "DEFAULT") - d, err := app.GenerateCompletions(completionMode, false, "lb") - if err != nil { - t.Errorf("invalid error: %v", err) - } - if fmt.Sprintf("%v", d) == fmt.Sprintf("%v", v) || fmt.Sprintf("%v", d) == fmt.Sprintf("%v", n) { - t.Errorf("invalid result, should filter") - } -} - -func TestProfileDisplay(t *testing.T) { - p := app.Profile{Name: "_abc-test-awera-zzz"} - if p.Display() != "TEST-AWERA-ZZZ" { - t.Errorf("invalid display: %s", p.Display()) - } -} - -func TestProfileEnv(t *testing.T) { - p := app.Profile{Name: "_abc-test-awera-zzz"} - if p.Env() != "LOCKBOX_COMPLETION_FUNCTION=TEST-AWERA-ZZZ" { - t.Error("invalid env") + if !strings.Contains(v[0], need) { + t.Errorf("invalid output, bad shell generation: %v", v) } } diff --git a/internal/app/core.go b/internal/app/core.go @@ -59,8 +59,6 @@ const ( CompletionsBashCommand = "bash" // CompletionsCommand are used to generate shell completions CompletionsCommand = "completions" - // CompletionsHelpCommand displays information about shell completions - CompletionsHelpCommand = "help" // ReKeyCommand will rekey the underlying database ReKeyCommand = "rekey" // MultiLineCommand handles multi-line inserts (when not piped) @@ -101,13 +99,13 @@ type ( } // Documentation is how documentation segments are templated Documentation struct { - Executable string - MoveCommand string - RemoveCommand string - ReKeyCommand string - CompletionsCommand string - CompletionsHelpCommand string - ReKey struct { + Executable string + MoveCommand string + RemoveCommand string + ReKeyCommand string + CompletionsCommand string + CompletionsEnv string + ReKey struct { Store string KeyFile string Key string @@ -191,7 +189,6 @@ func Usage(verbose bool, exe string) ([]string, error) { var results []string results = append(results, command(ClipCommand, "entry", "copy the entry's value into the clipboard")) results = append(results, command(CompletionsCommand, "<shell>", "generate completions via auto-detection")) - results = append(results, subCommand(CompletionsCommand, CompletionsHelpCommand, "", "show help information for completions")) for _, c := range completionTypes { results = append(results, subCommand(CompletionsCommand, c, "", fmt.Sprintf("generate %s completions", c))) } @@ -219,12 +216,12 @@ func Usage(verbose bool, exe string) ([]string, error) { if verbose { results = append(results, "") document := Documentation{ - Executable: filepath.Base(exe), - MoveCommand: MoveCommand, - RemoveCommand: RemoveCommand, - ReKeyCommand: ReKeyCommand, - CompletionsCommand: CompletionsCommand, - CompletionsHelpCommand: CompletionsHelpCommand, + Executable: filepath.Base(exe), + MoveCommand: MoveCommand, + RemoveCommand: RemoveCommand, + ReKeyCommand: ReKeyCommand, + CompletionsCommand: CompletionsCommand, + CompletionsEnv: config.EnvDefaultCompletionKey, } document.ReKey.Store = setDocFlag(config.ReKeyStoreFlag) document.ReKey.Key = setDocFlag(config.ReKeyKeyFlag) diff --git a/internal/app/core_test.go b/internal/app/core_test.go @@ -9,11 +9,11 @@ import ( func TestUsage(t *testing.T) { u, _ := app.Usage(false, "lb") - if len(u) != 26 { + if len(u) != 25 { t.Errorf("invalid usage, out of date? %d", len(u)) } u, _ = app.Usage(true, "lb") - if len(u) != 109 { + if len(u) != 107 { t.Errorf("invalid verbose usage, out of date? %d", len(u)) } for _, usage := range u { diff --git a/internal/app/doc/completions.txt b/internal/app/doc/completions.txt @@ -1,5 +1,4 @@ -Completions are available for certain shells and, by default, assume all -features of `{{ $.Executable }}` are enabled and available. When changing certain environment -flags it may be useful to change the completion profile to more closely match -the restricted command options, run `{{ $.Executable }} {{ $.CompletionsCommand }} {{ $.CompletionsHelpCommand }}` for information -on how best to alter completion outputs. +Completions are available for certain shells and detect available completions +determined by other environment settings. Completion command detection can be +disabled by setting {{ $.CompletionsEnv }}. Generation of completions are +handled by `{{ $.Executable }} {{ $.CompletionsCommand }}` diff --git a/internal/app/doc/fish.sh b/internal/app/doc/fish.sh @@ -26,19 +26,15 @@ end {{- end}} function {{ $.Executable }}-completions -{{- if eq (len $.Profiles) 1 }} - {{ $.DefaultProfile.Name }} -{{- else}} -{{- range $idx, $profile := $.Profiles }} -{{- if not $profile.IsDefault }} - if [ "{{ $.CompletionEnv }}" = "{{ $profile.Display }}" ] - {{ $profile.Name }} +{{- range $idx, $prof := $.Profiles }} +{{- if not $prof.IsDefault }} + if {{ $prof.Conditional }} + {{ $prof.Name }} return end {{- end}} {{- end}} {{ $.DefaultProfile.Name }} -{{- end}} end {{ $.Executable }}-completions diff --git a/internal/app/doc/shell.sh b/internal/app/doc/shell.sh @@ -1,18 +1,13 @@ _{{ $.Executable }}() { -{{- if eq (len $.Profiles) 1 }} + if [ -z "{{ $.DefaultCompletion }}" ] || [ "{{ $.DefaultCompletion }}" != "{{ $.IsYes }}" ]; then + {{- range $idx, $prof := $.Profiles }} + {{- if not $prof.IsDefault }} + if {{ $prof.Conditional }}; then + {{ $prof.Name }} + return + fi + {{- end }} + {{- end }} + fi {{ $.DefaultProfile.Name }} -{{- else}} - case "{{ $.CompletionEnv }}" in -{{- range $idx, $profile := $.Profiles }} -{{- if not $profile.IsDefault }} - "{{ $profile.Display }}") - {{ $profile.Name }} - ;; -{{- end}} -{{- end}} - *) - {{ $.DefaultProfile.Name }} - ;; - esac -{{- end}} } diff --git a/internal/app/info.go b/internal/app/info.go @@ -72,11 +72,7 @@ func info(command string, args []string) ([]string, error) { case 0: shell = filepath.Base(os.Getenv("SHELL")) case 1: - sub := args[0] - if sub == CompletionsHelpCommand { - return GenerateCompletions("", true, exe) - } - shell = sub + shell = args[0] default: return nil, errors.New("invalid completions subcommand") } @@ -86,7 +82,7 @@ func info(command string, args []string) ([]string, error) { default: return nil, fmt.Errorf("unknown completion type: %s", shell) } - return GenerateCompletions(shell, false, exe) + return GenerateCompletions(shell, exe) } return nil, nil } diff --git a/internal/app/info_test.go b/internal/app/info_test.go @@ -99,25 +99,17 @@ func TestCompletionInfo(t *testing.T) { } } } - var buf bytes.Buffer - ok, err := app.Info(&buf, "completions", []string{"help"}) - if !ok || err != nil { - t.Errorf("invalid error: %v", err) - } - s := buf.String() - if s == "" { - t.Error("nothing written") - } - if _, err = app.Info(&buf, "completions", []string{"helps"}); err.Error() != "unknown completion type: helps" { + var buf bytes.Buffer + if _, err := app.Info(&buf, "completions", []string{"helps"}); err.Error() != "unknown completion type: helps" { t.Errorf("invalid error: %v", err) } os.Clearenv() os.Setenv("SHELL", "bad") - if _, err = app.Info(&buf, "completions", []string{}); err.Error() != "unknown completion type: bad" { + if _, err := app.Info(&buf, "completions", []string{}); err.Error() != "unknown completion type: bad" { t.Errorf("invalid error: %v", err) } - if _, err = app.Info(&buf, "completions", []string{"bash"}); err != nil { + if _, err := app.Info(&buf, "completions", []string{"bash"}); err != nil { t.Errorf("invalid error: %v", err) } } diff --git a/internal/config/core.go b/internal/config/core.go @@ -44,6 +44,8 @@ const ( // sub categories clipCategory keyCategory = "CLIP_" totpCategory keyCategory = "TOTP_" + // YesValue are yes (on) values + YesValue = yes ) var ( diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -30,7 +30,7 @@ const ( ) var ( - registry = []printer{EnvStore, envKeyMode, envKey, EnvNoClip, EnvNoColor, EnvInteractive, EnvReadOnly, EnvTOTPToken, EnvFormatTOTP, EnvMaxTOTP, EnvTOTPColorBetween, EnvClipPaste, EnvClipCopy, EnvClipMax, EnvPlatform, EnvNoTOTP, EnvHookDir, EnvClipOSC52, EnvKeyFile, EnvModTime, EnvJSONDataOutput, EnvHashLength, EnvConfig, envConfigExpands, EnvCompletion} + registry = []printer{EnvStore, envKeyMode, envKey, EnvNoClip, EnvNoColor, EnvInteractive, EnvReadOnly, EnvTOTPToken, EnvFormatTOTP, EnvMaxTOTP, EnvTOTPColorBetween, EnvClipPaste, EnvClipCopy, EnvClipMax, EnvPlatform, EnvNoTOTP, EnvHookDir, EnvClipOSC52, EnvKeyFile, EnvModTime, EnvJSONDataOutput, EnvHashLength, EnvConfig, envConfigExpands, EnvDefaultCompletion} // Platforms represent the platforms that lockbox understands to run on Platforms = []string{MacOSPlatform, WindowsLinuxPlatform, LinuxXPlatform, LinuxWaylandPlatform} // TOTPDefaultColorWindow is the default coloring rules for totp @@ -81,6 +81,16 @@ var ( }, defaultValue: false, } + // EnvDefaultCompletion disable completion detection + EnvDefaultCompletion = EnvironmentBool{ + environmentBase: environmentBase{ + subKey: "DEFAULT_COMPLETION", + desc: "Use the default completion set (disable detection).", + }, + defaultValue: false, + } + // EnvDefaultCompletionKey is the key for default completion handling + EnvDefaultCompletionKey = EnvDefaultCompletion.key() // EnvNoColor indicates if color outputs are disabled EnvNoColor = EnvironmentBool{environmentBase: environmentBase{ subKey: "NOCOLOR", @@ -172,15 +182,7 @@ paths: %v Note that this setting is not output as part of the environment.`, noEnvironment, detectEnvironment, detectEnvironmentPaths), }, canDefault: true, defaultValue: detectEnvironment, allowed: []string{detectEnvironment, fileExample, noEnvironment}} - // EnvCompletion is the completion method to use - EnvCompletion = EnvironmentString{environmentBase: environmentBase{ - subKey: "COMPLETION_FUNCTION", - desc: "Use to select the non-default completions, please review the shell completion help for more information.", - requirement: "must be exported via a shell variable", - }, canDefault: false} - // EnvironmentCompletionKey is the underlying key for managing completion functions - EnvironmentCompletionKey = EnvCompletion.key() - envKeyMode = EnvironmentString{ + envKeyMode = EnvironmentString{ environmentBase: environmentBase{ subKey: "KEYMODE", requirement: "must be set to a valid mode when using a key", desc: fmt.Sprintf(`How to retrieve the database store password. Set to '%s' when only using a key file. @@ -303,6 +305,10 @@ func ParseJSONOutput() (JSONOutputMode, error) { return JSONDataOutputBlank, fmt.Errorf("invalid JSON output mode: %s", val) } +func exportProfileKeyValue(e environmentBase, val string) string { + return fmt.Sprintf("\"$%s\" = \"%s\"", e.key(), val) +} + func newProfile(keys []string) CompletionProfile { p := CompletionProfile{} p.Clip = true @@ -316,16 +322,16 @@ func newProfile(keys []string) CompletionProfile { name = fmt.Sprintf("%s%s-", name, k) switch k { case askProfile: - e = append(e, envKeyMode.KeyValue(string(askKeyMode))) + e = append(e, exportProfileKeyValue(envKeyMode.environmentBase, string(askKeyMode))) p.List = false case noTOTPProfile: - e = append(e, EnvNoTOTP.KeyValue(yes)) + e = append(e, exportProfileKeyValue(EnvNoTOTP.environmentBase, yes)) p.TOTP = false case noClipProfile: - e = append(e, EnvNoClip.KeyValue(yes)) + e = append(e, exportProfileKeyValue(EnvNoClip.environmentBase, yes)) p.Clip = false case roProfile: - e = append(e, EnvReadOnly.KeyValue(yes)) + e = append(e, exportProfileKeyValue(EnvReadOnly.environmentBase, yes)) p.Write = false } } diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -65,6 +65,10 @@ func TestIsNoClip(t *testing.T) { checkYesNo("LOCKBOX_NOCLIP", t, config.EnvNoClip, false) } +func TestDefaultCompletions(t *testing.T) { + checkYesNo("LOCKBOX_DEFAULT_COMPLETION", t, config.EnvDefaultCompletion, false) +} + func TestTOTP(t *testing.T) { os.Setenv("LOCKBOX_TOTP", "abc") if config.EnvTOTPToken.Get() != "abc" {