lockbox

password manager
Log | Files | Refs | README | LICENSE

commit f380c603ce6e09f85bcbcc663b5bbe71e0fb2ade
parent 1ae358783385aca712e96daad12d76953f83af4e
Author: Sean Enck <sean@ttypty.com>
Date:   Sun, 15 Jun 2025 08:11:10 -0400

control what is displayed in readonly mode

Diffstat:
Mcmd/lb/main.go | 8+++-----
Minternal/app/commands/core.go | 22++++++++++++++++++++--
Ainternal/app/commands/core_test.go | 25+++++++++++++++++++++++++
Minternal/app/completions/core.go | 3++-
Minternal/app/completions/core_test.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++------------------
Minternal/app/help/core.go | 18+++++++++++++-----
Minternal/app/help/core_test.go | 25+++++++++++++++++++++++++
7 files changed, 137 insertions(+), 31 deletions(-)

diff --git a/cmd/lb/main.go b/cmd/lb/main.go @@ -7,7 +7,6 @@ import ( "os" "os/exec" "runtime/debug" - "slices" "strings" "time" @@ -80,10 +79,9 @@ func run() error { if err != nil { return err } - if slices.Contains(commands.IsReadOnly, command) { - if config.EnvReadOnly.Get() { - return fmt.Errorf("%s is not allowed in read-only", command) - } + res := commands.AllowedInReadOnly(command) + if len(res) != 1 { + return fmt.Errorf("%s is not allowed in read-only", command) } switch command { case commands.ReKey: diff --git a/internal/app/commands/core.go b/internal/app/commands/core.go @@ -1,6 +1,12 @@ // Package commands defines available commands within the app package commands +import ( + "slices" + + "git.sr.ht/~enckse/lockbox/internal/config" +) + const ( // TOTP is the parent of totp and by defaults generates a rotating token TOTP = "totp" @@ -72,8 +78,20 @@ var ( }{"keyfile", "nokey"} ) -// IsReadOnly are commands blocked in readonly mode -var IsReadOnly = []string{Move, Insert, Unset, Remove} +// AllowedInReadOnly indicates any commands that are allowed in readonly mode +func AllowedInReadOnly(cmds ...string) []string { + if config.EnvReadOnly.Get() { + var allowed []string + for _, item := range cmds { + if slices.Contains([]string{Move, Insert, Unset, Remove, ReKey}, item) { + continue + } + allowed = append(allowed, item) + } + return allowed + } + return cmds +} // ReKeyArgs is the base definition of re-keying args type ReKeyArgs struct { diff --git a/internal/app/commands/core_test.go b/internal/app/commands/core_test.go @@ -0,0 +1,25 @@ +package commands_test + +import ( + "testing" + + "git.sr.ht/~enckse/lockbox/internal/app/commands" + "git.sr.ht/~enckse/lockbox/internal/config/store" +) + +func TestIsReadOnly(t *testing.T) { + defer store.Clear() + if res := commands.AllowedInReadOnly("insert", "xyz"); len(res) != 2 { + t.Error("invalid, is not readonly") + } + store.SetBool("LOCKBOX_READONLY", true) + if res := commands.AllowedInReadOnly("insert"); len(res) != 0 { + t.Error("invalid, is not readonly") + } + if res := commands.AllowedInReadOnly("insert", "show"); len(res) != 1 { + t.Error("invalid, is not readonly") + } + if res := commands.AllowedInReadOnly("show"); len(res) != 1 { + t.Error("invalid, is not readonly") + } +} diff --git a/internal/app/completions/core.go b/internal/app/completions/core.go @@ -68,7 +68,8 @@ func Generate(completionType, exe string) ([]string, error) { ExportCommand: fmt.Sprintf("%s %s %s", exe, commands.Env, commands.Completions), } - c.Options = []string{commands.Help, commands.List, commands.Show, commands.Version, commands.JSON, commands.Groups, commands.Move, commands.Remove, commands.Insert, commands.Unset} + c.Options = commands.AllowedInReadOnly(commands.Help, commands.List, commands.Show, commands.Version, commands.JSON, commands.Groups, commands.Move, commands.Remove, commands.Insert, commands.Unset) + canClip := config.EnvFeatureClip.Get() if canClip { c.Options = append(c.Options, commands.Clip) diff --git a/internal/app/completions/core_test.go b/internal/app/completions/core_test.go @@ -9,15 +9,60 @@ import ( "git.sr.ht/~enckse/lockbox/internal/config/store" ) +var tests = map[string]string{ + "zsh": "typeset -A opt_args", + "bash": "local cur opts", +} + func TestCompletions(t *testing.T) { - for k, v := range map[string]string{ - "zsh": "typeset -A opt_args", - "bash": "local cur opts", - } { + for k, v := range tests { testCompletion(t, k, v) } } +func TestCompletionReadOnly(t *testing.T) { + defer store.Clear() + for _, b := range []bool{true, false} { + store.SetBool("LOCKBOX_READONLY", b) + for k := range tests { + v, _ := completions.Generate(k, "lb") + res := strings.Join(v, "\n") + for _, needs := range []string{`}rm`, `}insert`, `}mv`, `}unset`} { + has := strings.Contains(res, needs) + if has { + if !b { + continue + } + t.Errorf("%s found, unwanted (shell %s)", needs, k) + } else { + if !b { + t.Errorf("%s required, not found (shell %s)", needs, k) + } + } + } + } + } +} + +func TestFeatures(t *testing.T) { + defer store.Clear() + type counts struct { + cmd string + with int + without int + } + for k := range tests { + for _, feature := range []counts{{"clip", 5, 1}, {"totp", 9, 1}} { + store.Clear() + key := fmt.Sprintf("LOCKBOX_FEATURE_%s", strings.ToUpper(feature.cmd)) + store.SetBool(key, true) + testCompletionFeature(t, k, feature.cmd, feature.with) + store.SetBool(key, false) + testCompletionFeature(t, k, feature.cmd, feature.without) + } + } +} + func testCompletionFeature(t *testing.T, completionMode, cmd string, expect int) { e := expect if cmd == "totp" && completionMode == "bash" { @@ -30,7 +75,6 @@ func testCompletionFeature(t *testing.T, completionMode, cmd string, expect int) } func testCompletion(t *testing.T, completionMode, need string) { - defer store.Clear() v, err := completions.Generate(completionMode, "lb") if err != nil { t.Errorf("invalid error: %v", err) @@ -41,17 +85,4 @@ func testCompletion(t *testing.T, completionMode, need string) { if !strings.Contains(v[0], need) { t.Errorf("invalid output, bad shell generation: %v", v) } - type counts struct { - cmd string - with int - without int - } - for _, feature := range []counts{{"clip", 5, 1}, {"totp", 9, 1}} { - store.Clear() - key := fmt.Sprintf("LOCKBOX_FEATURE_%s", strings.ToUpper(feature.cmd)) - store.SetBool(key, true) - testCompletionFeature(t, completionMode, feature.cmd, feature.with) - store.SetBool(key, false) - testCompletionFeature(t, completionMode, feature.cmd, feature.without) - } } diff --git a/internal/app/help/core.go b/internal/app/help/core.go @@ -76,6 +76,7 @@ func Usage(verbose bool, exe string) ([]string, error) { isGroup = "group" ) var results []string + isReadOnly := config.EnvReadOnly.Get() canClip := config.EnvFeatureClip.Get() if canClip { results = append(results, command(commands.Clip, isEntry, "copy the entry's value into the clipboard")) @@ -88,14 +89,16 @@ func Usage(verbose bool, exe string) ([]string, error) { results = append(results, command(commands.Help, "", "show this usage information")) results = append(results, subCommand(commands.Help, commands.HelpAdvanced, "", "display verbose help information")) results = append(results, subCommand(commands.Help, commands.HelpConfig, "", "display verbose configuration information")) - results = append(results, command(commands.Insert, isEntry, "insert a new entry into the store")) - results = append(results, command(commands.Unset, isEntry, "clear an entry value")) + if !isReadOnly { + results = append(results, command(commands.Insert, isEntry, "insert a new entry into the store")) + results = append(results, command(commands.Unset, isEntry, "clear an entry value")) + results = append(results, command(commands.Move, fmt.Sprintf("%s %s", isGroup, isGroup), "move a group from source to destination")) + results = append(results, command(commands.ReKey, "", "rekey/reinitialize the database credentials")) + results = append(results, command(commands.Remove, isGroup, "remove an entry from the store")) + } results = append(results, command(commands.JSON, isFilter, "display detailed information")) results = append(results, command(commands.List, isFilter, "list entries")) results = append(results, command(commands.Groups, isFilter, "list groups")) - results = append(results, command(commands.Move, fmt.Sprintf("%s %s", isGroup, isGroup), "move a group from source to destination")) - results = append(results, command(commands.ReKey, "", "rekey/reinitialize the database credentials")) - results = append(results, command(commands.Remove, isGroup, "remove an entry from the store")) results = append(results, command(commands.Show, isEntry, "show the entry's value")) canTOTP := config.EnvFeatureTOTP.Get() if canTOTP { @@ -169,6 +172,11 @@ func Usage(verbose bool, exe string) ([]string, error) { break } } + if !skip { + if section == "rekey" || section == "globs" { + skip = isReadOnly + } + } if skip { continue } diff --git a/internal/app/help/core_test.go b/internal/app/help/core_test.go @@ -44,3 +44,28 @@ func TestFlags(t *testing.T) { } } } + +func TestReadOnly(t *testing.T) { + defer store.Clear() + check := func(require bool) { + u, _ := help.Usage(true, "lb") + text := strings.Join(u, "\n") + for _, need := range []string{" mv ", " rm ", "[rekey]", " insert ", " unset ", "[globs]"} { + has := strings.Contains(text, need) + if has { + if require { + continue + } + t.Errorf("has unwanted text: %s", need) + } else { + if require { + t.Errorf("missing required text: %s", need) + } + } + } + } + + check(true) + store.SetBool("LOCKBOX_READONLY", true) + check(false) +}