commit 277f8ee2cd7e5977b09322365a6c5b4ef675745e
parent 1e118e349d1d6fd9756206d3f9b0a9bdab6e9f72
Author: Sean Enck <sean@ttypty.com>
Date: Wed, 2 Nov 2022 18:07:06 -0400
adding ability to have hooks
Diffstat:
11 files changed, 178 insertions(+), 9 deletions(-)
diff --git a/Makefile b/Makefile
@@ -21,7 +21,7 @@ $(TESTDIR):
cd $@ && go test
check: $(TARGET) $(TESTDIR)
- LB_BUILD=$(TARGET) TEST_DATA=$(BUILD) go run scripts/check.go 2>&1 | sed "s#$(PWD)/$(DATA)##g" | sed 's/^[0-9][0-9][0-9][0-9][0-9][0-9]$$/XXXXXX/g' > $(ACTUAL)
+ LB_BUILD=$(TARGET) TEST_DATA=$(BUILD) SCRIPTS=$(PWD)/scripts/ go run scripts/check.go 2>&1 | sed "s#$(PWD)/$(DATA)##g" | sed 's/^[0-9][0-9][0-9][0-9][0-9][0-9]$$/XXXXXX/g' > $(ACTUAL)
diff -u $(ACTUAL) scripts/tests.expected.log
clean:
diff --git a/cmd/vers.txt b/cmd/vers.txt
@@ -1 +1 @@
-v22.10.10
-\ No newline at end of file
+v22.11.00
+\ No newline at end of file
diff --git a/internal/backend/actions.go b/internal/backend/actions.go
@@ -4,6 +4,8 @@ package backend
import (
"errors"
"os"
+ "os/exec"
+ "path/filepath"
"strings"
"github.com/enckse/lockbox/internal/inputs"
@@ -11,6 +13,48 @@ import (
"github.com/tobischo/gokeepasslib/v3/wrappers"
)
+// NewHook will create a new hook type
+func NewHook(path string, a ActionMode) (Hook, error) {
+ if strings.TrimSpace(path) == "" {
+ return Hook{}, errors.New("empty path is not allowed for hooks")
+ }
+ dir := inputs.EnvOrDefault(inputs.HookDirEnv, "")
+ if dir == "" {
+ return Hook{enabled: false}, nil
+ }
+ if !pathExists(dir) {
+ return Hook{}, errors.New("hook directory does NOT exist")
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return Hook{}, err
+ }
+ scripts := []string{}
+ for _, e := range entries {
+ if e.IsDir() {
+ return Hook{}, errors.New("found subdirectory in hookdir")
+ }
+ scripts = append(scripts, filepath.Join(dir, e.Name()))
+ }
+ return Hook{path: path, mode: a, enabled: len(scripts) > 0, scripts: scripts}, nil
+}
+
+// Run will execute any scripts configured as hooks
+func (h Hook) Run(mode HookMode) error {
+ if !h.enabled {
+ return nil
+ }
+ for _, s := range h.scripts {
+ c := exec.Command(s, string(mode), string(h.mode), h.path)
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ if err := c.Run(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
func (t *Transaction) act(cb action) error {
if !t.valid {
return errors.New("invalid transaction")
@@ -183,11 +227,21 @@ func (t *Transaction) Move(src QueryEntity, dst string) error {
if err != nil {
return err
}
- isMove := dst != src.Path
+ action := MoveAction
+ if dst == src.Path {
+ action = InsertAction
+ }
+ hook, err := NewHook(src.Path, action)
+ if err != nil {
+ return err
+ }
multi := len(strings.Split(strings.TrimSpace(src.Value), "\n")) > 1
return t.change(func(c Context) error {
+ if err := hook.Run(HookPre); err != nil {
+ return err
+ }
c.removeEntity(sOffset, sTitle)
- if isMove {
+ if action == MoveAction {
c.removeEntity(dOffset, dTitle)
}
e := gokeepasslib.NewEntry()
@@ -211,7 +265,7 @@ func (t *Transaction) Move(src QueryEntity, dst string) error {
e.Values = append(e.Values, protectedValue(field, v))
c.insertEntity(dOffset, dTitle, e)
- return nil
+ return hook.Run(HookPost)
})
}
@@ -239,9 +293,19 @@ func (t *Transaction) RemoveAll(entities []QueryEntity) error {
if err != nil {
return err
}
+ hook, err := NewHook(entity.Path, RemoveAction)
+ if err != nil {
+ return err
+ }
+ if err := hook.Run(HookPre); err != nil {
+ return err
+ }
if ok := c.removeEntity(offset, title); !ok {
return errors.New("failed to remove entity")
}
+ if err := hook.Run(HookPost); err != nil {
+ return err
+ }
}
return nil
})
diff --git a/internal/backend/actions_test.go b/internal/backend/actions_test.go
@@ -3,6 +3,8 @@ package backend_test
import (
"fmt"
"os"
+ "path/filepath"
+ "strings"
"testing"
"github.com/enckse/lockbox/internal/backend"
@@ -17,6 +19,7 @@ func fullSetup(t *testing.T, keep bool) *backend.Transaction {
os.Setenv("LOCKBOX_KEY", "test")
os.Setenv("LOCKBOX_KEYMODE", "plaintext")
os.Setenv("LOCKBOX_TOTP", "totp")
+ os.Setenv("LOCKBOX_HOOKDIR", "")
tr, err := backend.NewTransaction()
if err != nil {
t.Errorf("failed: %v", err)
@@ -219,3 +222,55 @@ func check(t *testing.T, checks ...string) error {
}
return nil
}
+
+func TestHooks(t *testing.T) {
+ os.Setenv("LOCKBOX_HOOKDIR", "")
+ h, err := backend.NewHook("a", backend.InsertAction)
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if err := h.Run(backend.HookPre); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := backend.NewHook("", backend.InsertAction); err.Error() != "empty path is not allowed for hooks" {
+ t.Errorf("wrong error: %v", err)
+ }
+ os.Setenv("LOCKBOX_HOOKDIR", "is_garbage")
+ if _, err := backend.NewHook("b", backend.InsertAction); err.Error() != "hook directory does NOT exist" {
+ t.Errorf("wrong error: %v", err)
+ }
+ testPath := "hooks.kdbx"
+ os.RemoveAll(testPath)
+ if err := os.MkdirAll(testPath, 0755); err != nil {
+ t.Errorf("failed, mkdir: %v", err)
+ }
+ os.Setenv("LOCKBOX_HOOKDIR", testPath)
+ h, err = backend.NewHook("a", backend.InsertAction)
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if err := h.Run(backend.HookPre); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ sub := filepath.Join(testPath, "subdir")
+ if err := os.MkdirAll(sub, 0755); err != nil {
+ t.Errorf("failed, mkdir sub: %v", err)
+ }
+ if _, err := backend.NewHook("b", backend.InsertAction); err.Error() != "found subdirectory in hookdir" {
+ t.Errorf("wrong error: %v", err)
+ }
+ if err := os.RemoveAll(sub); err != nil {
+ t.Errorf("failed rmdir: %v", err)
+ }
+ script := filepath.Join(testPath, "testscript")
+ if err := os.WriteFile(script, []byte{}, 0644); err != nil {
+ t.Errorf("unable to write script: %v", err)
+ }
+ h, err = backend.NewHook("a", backend.InsertAction)
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if err := h.Run(backend.HookPre); strings.Contains("fork/exec", err.Error()) {
+ t.Errorf("wrong error: %v", err)
+ }
+}
diff --git a/internal/backend/types.go b/internal/backend/types.go
@@ -12,6 +12,10 @@ type (
QueryMode int
// ValueMode indicates what to do with the store value of the entity
ValueMode int
+ // ActionMode represents activities performed via transactions
+ ActionMode string
+ // HookMode are hook operations the user can tie to
+ HookMode string
// QueryOptions indicates how to find entities
QueryOptions struct {
Mode QueryMode
@@ -37,6 +41,13 @@ type (
Context struct {
db *gokeepasslib.Database
}
+ // Hook represents a runnable user-defined hook
+ Hook struct {
+ path string
+ mode ActionMode
+ enabled bool
+ scripts []string
+ }
)
const (
@@ -54,6 +65,19 @@ const (
)
const (
+ // MoveAction represents changes via moves, like the Move command
+ MoveAction ActionMode = "mv"
+ // InsertAction represents changes via inserts, like the Insert command
+ InsertAction ActionMode = "insert"
+ // RemoveAction represents changes via deletions, like Remove or globbed remove commands
+ RemoveAction ActionMode = "rm"
+ // HookPre are triggers BEFORE an action is performed on an entity
+ HookPre HookMode = "pre"
+ // HookPost are triggers AFTER an action is performed on an entity
+ HookPost HookMode = "post"
+)
+
+const (
// BlankValue will not decrypt secrets, empty value
BlankValue ValueMode = iota
// HashedValue will decrypt and then hash the password
diff --git a/internal/inputs/env.go b/internal/inputs/env.go
@@ -56,6 +56,8 @@ const (
colorWindowSpan = ":"
detectedValue = "(detected)"
noTOTPEnv = prefixKey + "NOTOTP"
+ // HookDirEnv represents a stored location for user hooks
+ HookDirEnv = prefixKey + "HOOKDIR"
)
var (
@@ -282,5 +284,6 @@ func ListEnvironmentVariables(showValues bool) []string {
results = append(results, e.formatEnvironmentVariable(false, clipMaxEnv, fmt.Sprintf("%d", defaultMaxClipboard), "override the amount of time before totp clears the clipboard (e.g. 10), must be an integer", []string{"integer"}))
results = append(results, e.formatEnvironmentVariable(false, PlatformEnv, detectedValue, "override the detected platform", []string{MacOSPlatform, LinuxWaylandPlatform, LinuxXPlatform, WindowsLinuxPlatform}))
results = append(results, e.formatEnvironmentVariable(false, noTOTPEnv, isNo, "disable TOTP integrations", isYesNoArgs))
+ results = append(results, e.formatEnvironmentVariable(false, HookDirEnv, "", "the path to hooks to execute on actions against the database", []string{"directory"}))
return results
}
diff --git a/internal/inputs/env_test.go b/internal/inputs/env_test.go
@@ -150,7 +150,7 @@ func TestGetKey(t *testing.T) {
func TestListVariables(t *testing.T) {
vars := inputs.ListEnvironmentVariables(false)
- if len(vars) != 15 {
+ if len(vars) != 16 {
t.Errorf("invalid env count, outdated? %d", len(vars))
}
}
diff --git a/scripts/check.go b/scripts/check.go
@@ -56,7 +56,9 @@ func totpList() {
}
func main() {
- store := filepath.Join(os.Getenv("TEST_DATA"), fmt.Sprintf("%s.kdbx", time.Now().Format("20060102150405")))
+ path := os.Getenv("TEST_DATA")
+ store := filepath.Join(path, fmt.Sprintf("%s.kdbx", time.Now().Format("20060102150405")))
+ os.Setenv("LOCKBOX_HOOKDIR", "")
os.Setenv("LOCKBOX_STORE", store)
os.Setenv("LOCKBOX_KEY", testKey)
os.Setenv("LOCKBOX_TOTP", "totp")
@@ -106,6 +108,7 @@ func main() {
rm("keys/k2/t1/*")
fmt.Println()
ls()
+ os.Setenv("LOCKBOX_HOOKDIR", filepath.Join(os.Getenv("SCRIPTS"), "hooks"))
rm("keys/k2/*")
fmt.Println()
ls()
diff --git a/scripts/hooks/all.sh b/scripts/hooks/all.sh
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+echo "$@"
diff --git a/scripts/hooks/test.sh b/scripts/hooks/test.sh
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+echo "CALLED"
diff --git a/scripts/tests.expected.log b/scripts/tests.expected.log
@@ -77,5 +77,21 @@ selected entities:
keys/k2/t2/one
keys/k2/t2/one2
-delete entries? (y/N)
+delete entries? (y/N) pre rm keys/k2/one
+CALLED
+post rm keys/k2/one
+CALLED
+pre rm keys/k2/one2
+CALLED
+post rm keys/k2/one2
+CALLED
+pre rm keys/k2/t2/one
+CALLED
+post rm keys/k2/t2/one
+CALLED
+pre rm keys/k2/t2/one2
+CALLED
+post rm keys/k2/t2/one2
+CALLED
+
keys/k/one2