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:
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)
+}