Hello world 👋
Release / Build and publish (push) Successful in 23s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-10 22:34:22 +02:00
commit 430875f200
111 files changed
+22958

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
// Package browser opens URLs in the user's web browser.
package browser
import (
"errors"
"os"
"os/exec"
"runtime"
)
// ErrRemote is returned over SSH, where a browser would open on the remote
// machine rather than in front of the user.
var ErrRemote = errors.New("can't open a browser over SSH")
// Open starts the system's URL handler without waiting for it.
func Open(url string) error {
if os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" {
return ErrRemote
}
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
cmd = exec.Command("xdg-open", url)
}
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }() // reap the process
return nil
}
+92
View File
@@ -0,0 +1,92 @@
// Package clipboard copies text to the system clipboard, or via the OSC52
// terminal escape sequence over SSH or when no native clipboard exists.
package clipboard
import (
"crypto/sha256"
"errors"
"os"
tea "charm.land/bubbletea/v2"
"github.com/atotto/clipboard"
)
type Clipboard struct {
native bool
read func() (string, error)
write func(string) error
}
// ErrorMsg reports a failed clipboard write.
type ErrorMsg struct{ Err error }
// New picks the native clipboard locally and OSC52 over SSH.
// LAZYSECRETS_CLIPBOARD_FILE substitutes a file, for end-to-end tests that
// mustn't touch the real clipboard.
func New() Clipboard {
if path := os.Getenv("LAZYSECRETS_CLIPBOARD_FILE"); path != "" {
return Clipboard{
native: true,
read: func() (string, error) {
b, err := os.ReadFile(path)
return string(b), err
},
write: func(s string) error { return os.WriteFile(path, []byte(s), 0o600) },
}
}
remote := os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != ""
return Clipboard{
native: !remote && !clipboard.Unsupported,
read: clipboard.ReadAll,
write: clipboard.WriteAll,
}
}
// Native reports whether the system clipboard is used rather than OSC52.
func (c Clipboard) Native() bool { return c.native }
// Write puts s on the clipboard.
func (c Clipboard) Write(s string) tea.Cmd {
if !c.native {
return tea.SetClipboard(s)
}
return func() tea.Msg {
if err := c.write(s); err != nil {
return ErrorMsg{Err: err}
}
return nil
}
}
// ErrNoRead is returned where the clipboard can't be read back (OSC52).
var ErrNoRead = errors.New("clipboard can't be read here")
// Read returns the clipboard's text, where that's possible.
func (c Clipboard) Read() (string, error) {
if !c.native {
return "", ErrNoRead
}
return c.read()
}
// Fingerprint identifies copied text for Clear without keeping it in memory.
func Fingerprint(s string) [32]byte { return sha256.Sum256([]byte(s)) }
// Clear empties the clipboard if it still holds the text with fingerprint
// fp, so it doesn't wipe something the user copied since. OSC52 can't read
// the clipboard back, so there it always clears.
func (c Clipboard) Clear(fp [32]byte) tea.Cmd {
if !c.native {
return tea.SetClipboard("")
}
return func() tea.Msg {
cur, err := c.read()
if err != nil || Fingerprint(cur) != fp {
return nil
}
if err := c.write(""); err != nil {
return ErrorMsg{Err: err}
}
return nil
}
}
+33
View File
@@ -0,0 +1,33 @@
package clipboard
import "testing"
func fakeNative() (*Clipboard, *string) {
var board string
return &Clipboard{
native: true,
read: func() (string, error) { return board, nil },
write: func(s string) error { board = s; return nil },
}, &board
}
func TestClearOnlyIfUnchanged(t *testing.T) {
c, board := fakeNative()
c.Write("secret")()
if *board != "secret" {
t.Fatalf("board = %q", *board)
}
fp := Fingerprint("secret")
*board = "something the user copied later"
c.Clear(fp)()
if *board != "something the user copied later" {
t.Error("cleared text the user copied afterwards")
}
*board = "secret"
c.Clear(fp)()
if *board != "" {
t.Errorf("not cleared: %q", *board)
}
}
+166
View File
@@ -0,0 +1,166 @@
// Package commands defines custom commands: shell commands from the
// config, run on the selected item with its details filled in.
//
// customCommands:
// - key: K
// context: secrets
// description: Create Kubernetes secret
// command: kubectl create secret generic {{.Name}} -n {{.Prompt.namespace}} --from-file=value=/dev/stdin
// value: stdin
// prompts: [{name: namespace, title: Namespace, default: default}]
// output: popup
//
// Fields are shell-quoted when filled in ({{raw .Note}} opts out), and
// the secret value is never one of them: value: stdin or value: env:NAME
// hands it to the command without putting it on its command line.
package commands
import (
"fmt"
"regexp"
"strings"
"text/template"
)
// Contexts say where a command applies: the pane with focus (the detail
// pane counts as secrets), or anywhere.
const (
Sources = "sources"
Vaults = "vaults"
Secrets = "secrets"
Global = "global"
)
// Outputs say how a command runs.
const (
Terminal = "terminal" // in the full terminal, lazysecrets stepping aside
Popup = "popup" // output captured and shown in a popup
Quiet = "quiet" // in the background, success or failure in the footer
)
type Command struct {
Key string `yaml:"key"`
Context string `yaml:"context"`
Description string `yaml:"description"`
Command string `yaml:"command"`
Value string `yaml:"value"` // "", "stdin" or "env:NAME"
Confirm *bool `yaml:"confirm"` // defaults to true when the value is passed
Output string `yaml:"output"` // defaults to quiet
Prompts []Prompt `yaml:"prompts"`
}
// A Prompt asks for text before the command runs: {{.Prompt.name}}.
type Prompt struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
Default string `yaml:"default"`
}
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// Validate checks a command from the config.
func (c Command) Validate() error {
name := c.Description
if name == "" {
name = c.Command
}
switch {
case c.Command == "":
return fmt.Errorf("custom command %q has no command", c.Description)
case c.Context != Sources && c.Context != Vaults && c.Context != Secrets && c.Context != Global:
return fmt.Errorf("custom command %q: context %q is not sources, vaults, secrets or global", name, c.Context)
case c.Output != "" && c.Output != Terminal && c.Output != Popup && c.Output != Quiet:
return fmt.Errorf("custom command %q: output %q is not terminal, popup or quiet", name, c.Output)
}
if c.Value != "" {
if c.Context != Secrets {
return fmt.Errorf("custom command %q: value needs context: secrets", name)
}
env, isEnv := strings.CutPrefix(c.Value, "env:")
if c.Value != "stdin" && !(isEnv && envName.MatchString(env)) {
return fmt.Errorf("custom command %q: value %q is not stdin or env:NAME", name, c.Value)
}
if c.Value == "stdin" && c.Output == Terminal {
return fmt.Errorf("custom command %q: output: terminal needs the terminal's input; use value: env:NAME", name)
}
}
seen := map[string]bool{}
for _, p := range c.Prompts {
if p.Name == "" || seen[p.Name] {
return fmt.Errorf("custom command %q: prompts need distinct names", name)
}
seen[p.Name] = true
}
// A trial run catches unknown fields ({{.Nmae}}) and prompts.
trial := Item{Prompt: map[string]Q{}}
for _, p := range c.Prompts {
trial.Prompt[p.Name] = ""
}
if _, err := c.Render(trial); err != nil {
return fmt.Errorf("custom command %q: %w", name, err)
}
return nil
}
// Confirms reports whether to ask before running.
func (c Command) Confirms() bool {
if c.Confirm != nil {
return *c.Confirm
}
return c.Value != ""
}
// Out is the output mode, quiet unless set.
func (c Command) Out() string {
if c.Output == "" {
return Quiet
}
return c.Output
}
// Item is what's filled into a command: the selected item's details,
// shell-quoted when printed, and the answers to its prompts.
type Item struct {
Kind, Account, Source, SourceID Q
Vault, VaultURL Q
Name, Type, Version Q
Link, Reference, URL, Note, Expires Q
Prompt map[string]Q
}
// Q is a field, shell-quoted when printed in a template.
type Q string
func (q Q) String() string { return Quote(string(q)) }
var safe = regexp.MustCompile(`^[A-Za-z0-9_./:@%+=,-]+$`)
// Quote quotes s for a POSIX shell, leaving plain words as they are.
func Quote(s string) string {
if safe.MatchString(s) {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func (c Command) template() (*template.Template, error) {
return template.New("command").Option("missingkey=error").
Funcs(template.FuncMap{"raw": func(q Q) string { return string(q) }}).
Parse(c.Command)
}
// Render fills the item into the command.
func (c Command) Render(it Item) (string, error) {
t, err := c.template()
if err != nil {
return "", err
}
if it.Prompt == nil {
it.Prompt = map[string]Q{}
}
var b strings.Builder
if err := t.Execute(&b, it); err != nil {
return "", err
}
return b.String(), nil
}
+45
View File
@@ -0,0 +1,45 @@
package commands
import (
"strings"
"testing"
)
func TestRenderQuotes(t *testing.T) {
c := Command{Context: Secrets, Command: `kubectl create secret generic {{.Name}} -n {{.Prompt.ns}} # {{.Note}} {{raw .Name}}`}
got, err := c.Render(Item{Name: "db-password", Note: "it's $(rm -rf ~)", Prompt: map[string]Q{"ns": "team a"}})
if err != nil {
t.Fatal(err)
}
want := `kubectl create secret generic db-password -n 'team a' # 'it'\''s $(rm -rf ~)' db-password`
if got != want {
t.Errorf("got %s\nwant %s", got, want)
}
}
func TestValidate(t *testing.T) {
ok := Command{Context: Secrets, Command: "gh secret set {{.Name}}", Value: "stdin"}
if err := ok.Validate(); err != nil {
t.Errorf("valid command: %v", err)
}
if !ok.Confirms() || ok.Out() != Quiet {
t.Error("defaults: confirm with a value, quiet output")
}
for _, c := range []struct {
cmd Command
want string
}{
{Command{Context: "panes", Command: "x"}, "context"},
{Command{Context: Vaults, Command: "x", Value: "stdin"}, "needs context: secrets"},
{Command{Context: Secrets, Command: "x", Value: "env:1BAD"}, "not stdin or env:NAME"},
{Command{Context: Secrets, Command: "x", Value: "stdin", Output: Terminal}, "terminal's input"},
{Command{Context: Global, Command: "{{.Nope"}, "unclosed"},
{Command{Context: Global, Command: "x", Output: "loud"}, "output"},
{Command{Context: Global, Command: "echo {{.Nmae}}"}, "Nmae"},
{Command{Context: Global, Command: "echo {{.Prompt.ns}}"}, "ns"},
} {
if err := c.cmd.Validate(); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%+v: %v, want %q", c.cmd, err, c.want)
}
}
}
+224
View File
@@ -0,0 +1,224 @@
// Package config loads the optional YAML config file.
package config
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
)
type Config struct {
Icons *bool `yaml:"icons"`
ClipboardClear *time.Duration `yaml:"clipboardClear"`
// RevealTimeout hides a revealed value after this long; 0s never.
// Defaults to 60s.
RevealTimeout *time.Duration `yaml:"revealTimeout"`
// ExpiryWarningDays marks secrets, keys and certificates expiring
// within this many days; 0 marks only expired ones. Defaults to 30.
ExpiryWarningDays *int `yaml:"expiryWarningDays"`
// HideOnBlur hides a revealed value when the terminal loses focus.
// Defaults to true.
HideOnBlur *bool `yaml:"hideOnBlur"`
Timeout time.Duration `yaml:"timeout"`
// LoadDelay is how long the cursor must rest on a row before its
// contents load; 0s loads at once. Defaults to 150ms.
LoadDelay *time.Duration `yaml:"loadDelay"`
// SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it.
SourcesView string `yaml:"sourcesView"`
// History counts uses of items for the Recent list; defaults to true.
History *bool `yaml:"history"`
// UpdateCheck looks for a newer release once a day, in the
// background; defaults to true. U and lazysecrets update work either
// way.
UpdateCheck *bool `yaml:"updateCheck"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
Providers Providers `yaml:"providers"`
// LinkHandler sets how lazysecrets:// links open; see linkhandler.
LinkHandler LinkHandler `yaml:"linkHandler"`
// CustomCommands run shell commands on the selected item; see
// package commands.
CustomCommands []commands.Command `yaml:"customCommands"`
// Keybinding sets keys per context and action, replacing the
// defaults of each action given; see keys.Map.Apply.
Keybinding map[string]map[string][]string `yaml:"keybinding"`
// Theme maps roles to colours, like lazygit's gui.theme; see
// styles.Roles.
Theme map[string][]string `yaml:"theme"`
}
type LinkHandler struct {
// Terminal to open links in, overriding the one handler install
// detected: kitty, ghostty, wezterm, iterm2, terminal, alacritty,
// gnome-terminal, konsole or foot.
Terminal string `yaml:"terminal"`
// Command opens any other terminal: its arguments, with {cmd} where
// the lazysecrets command goes, e.g. [foot, "{cmd}"].
Command []string `yaml:"command"`
// Reuse hands links to a running lazysecrets, the one last used that
// can be brought into view, before opening a new window. Defaults to
// true.
Reuse *bool `yaml:"reuse"`
// Prompt offers to set up the link handler at startup when links
// don't open lazysecrets. Defaults to true.
Prompt *bool `yaml:"prompt"`
}
type Providers struct {
Azure Azure `yaml:"azure"`
Databricks Databricks `yaml:"databricks"`
Repos Repos `yaml:"repos"`
}
// Repos are git repositories' secret files (SOPS, .env). Inside a repo,
// that repo is the only one shown; elsewhere, the ones in Paths and under
// Roots.
type Repos struct {
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
Paths []string `yaml:"paths"`
// Roots are folders of repos. Defaults to ~/git; an empty list
// turns it off.
Roots *[]string `yaml:"roots"`
// Include and Exclude are globs relative to a repo's root (** for any
// depth), for repos without their own .lazysecrets.yaml. Without them,
// a repo's .sops.yaml creation_rules say where to look.
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
}
type Azure struct {
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
// HideSubscriptions lists subscriptions to leave out, by name or ID.
HideSubscriptions []string `yaml:"hideSubscriptions"`
// Vaults are shown under "Pinned vaults", for vaults that can't be
// discovered (e.g. data-plane access only).
Vaults []string `yaml:"vaults"`
}
type Databricks struct {
// Enabled defaults to true.
Enabled *bool `yaml:"enabled"`
HideProfiles []string `yaml:"hideProfiles"`
}
// Dir is $XDG_CONFIG_HOME/lazysecrets, defaulting to ~/.config/lazysecrets
// on every platform, like most terminal tools.
func Dir() (string, error) {
return xdgDir("XDG_CONFIG_HOME", ".config")
}
// StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to
// ~/.local/state/lazysecrets. It holds data the app writes, like favourites.
func StateDir() (string, error) {
return xdgDir("XDG_STATE_HOME", filepath.Join(".local", "state"))
}
func xdgDir(env, fallback string) (string, error) {
base := os.Getenv(env)
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
base = filepath.Join(home, fallback)
}
return filepath.Join(base, "lazysecrets"), nil
}
// Load reads path. A missing file is an empty config. Unknown keys are an
// error, so typos don't silently do nothing.
func Load(path string) (Config, error) {
return LoadAll([]string{path})
}
// LoadAll reads several config files in order, each on top of the ones
// before, like lazygit's LG_CONFIG_FILE: a later file overrides the
// settings it sets, and theme roles merge one by one. That lets an editor
// plugin add a generated theme file to the user's own config. Missing
// files are skipped.
func LoadAll(paths []string) (Config, error) {
var c Config
keybinding := map[string]map[string][]string{}
for _, path := range paths {
// Keybindings layer per action: a later file setting one action
// in a context keeps the others set before it.
c.Keybinding = nil
if err := decodeInto(&c, path); err != nil {
return c, err
}
for ctx, actions := range c.Keybinding {
if keybinding[ctx] == nil {
keybinding[ctx] = map[string][]string{}
}
for name, keys := range actions {
keybinding[ctx][name] = keys
}
}
}
c.Keybinding = keybinding
return c, c.validate(strings.Join(paths, ", "))
}
func decodeInto(c *Config, path string) error {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
dec := yaml.NewDecoder(bytes.NewReader(b))
dec.KnownFields(true)
if err := dec.Decode(c); err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("%s: %w", path, err)
}
return nil
}
func (c Config) validate(path string) error {
if v := c.SourcesView; v != "" && v != "grouped" && v != "flat" {
return fmt.Errorf("%s: sourcesView: %q is not grouped or flat", path, v)
}
for _, cmd := range c.CustomCommands {
if err := cmd.Validate(); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
}
if d := c.ExpiryWarningDays; d != nil && *d < 0 {
return fmt.Errorf("%s: expiryWarningDays: %d is negative", path, *d)
}
if d := c.LoadDelay; d != nil && (*d < 0 || *d > 5*time.Second) {
return fmt.Errorf("%s: loadDelay: %s is not between 0s and 5s", path, *d)
}
for i, v := range c.Providers.Azure.Vaults {
if !strings.HasPrefix(v, "https://") {
return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v)
}
}
return nil
}
// On reports whether an optional enabled flag is unset or true.
func On(enabled *bool) bool { return enabled == nil || *enabled }
// Hidden reports whether name or id appears in list, ignoring case.
func Hidden(list []string, name, id string) bool {
for _, h := range list {
if strings.EqualFold(h, name) || strings.EqualFold(h, id) {
return true
}
}
return false
}
+143
View File
@@ -0,0 +1,143 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func write(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoad(t *testing.T) {
c, err := Load(write(t, `
icons: false
sourcesView: flat
clipboardClear: 45s
timeout: 1m
providers:
azure:
hideSubscriptions: [platform-mgmt]
vaults:
- https://kv-foo.vault.azure.net/
databricks:
enabled: false
hideProfiles: [test]
`))
if err != nil {
t.Fatal(err)
}
if c.Icons == nil || *c.Icons || c.ClipboardClear == nil || *c.ClipboardClear != 45*time.Second ||
c.Timeout != time.Minute || len(c.Providers.Azure.Vaults) != 1 || c.Providers.Databricks.HideProfiles[0] != "test" {
t.Errorf("config = %+v", c)
}
if c.SourcesView != "flat" {
t.Errorf("sourcesView = %q", c.SourcesView)
}
if !On(c.Providers.Azure.Enabled) || On(c.Providers.Databricks.Enabled) {
t.Error("enabled flags wrong")
}
az := c.Providers.Azure
if !Hidden(az.HideSubscriptions, "PLATFORM-MGMT", "id") || Hidden(az.HideSubscriptions, "other", "id") {
t.Error("Hidden matching wrong")
}
}
func TestLoadMissingAndEmpty(t *testing.T) {
for name, path := range map[string]string{
"missing": filepath.Join(t.TempDir(), "nope.yaml"),
"empty": write(t, ""),
} {
c, err := Load(path)
if err != nil || c.Icons != nil || c.ClipboardClear != nil {
t.Errorf("%s: %+v, %v", name, c, err)
}
}
}
func TestLoadRejectsMistakes(t *testing.T) {
for name, content := range map[string]string{
"typo": "icon: true\n",
"bad vault": "providers:\n azure:\n vaults: [kv-foo]\n",
"old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n",
"duration": "timeout: soon\n",
"view": "sourcesView: tree\n",
} {
if _, err := Load(write(t, content)); err == nil {
t.Errorf("%s: no error", name)
} else if !strings.Contains(err.Error(), "config.yaml") {
t.Errorf("%s: error doesn't name the file: %v", name, err)
}
}
}
func TestLoadAllLayers(t *testing.T) {
user := write(t, `
icons: false
theme:
azureColor: [blue]
errorColor: [red, bold]
providers:
azure:
hideSubscriptions: [a]
`)
theme := write(t, `
theme:
azureColor: ["#82aaff"]
activeBorderColor: ["#ff966c", bold]
`)
c, err := LoadAll([]string{user, filepath.Join(t.TempDir(), "missing.yaml"), theme})
if err != nil {
t.Fatal(err)
}
if c.Icons == nil || *c.Icons || len(c.Providers.Azure.HideSubscriptions) != 1 {
t.Errorf("user settings lost: %+v", c)
}
want := map[string][]string{
"azureColor": {"#82aaff"}, // overridden by the later file
"errorColor": {"red", "bold"}, // kept from the user's file
"activeBorderColor": {"#ff966c", "bold"},
}
for role, v := range want {
if strings.Join(c.Theme[role], ",") != strings.Join(v, ",") {
t.Errorf("theme %s = %v, want %v", role, c.Theme[role], v)
}
}
}
func TestLoadDelay(t *testing.T) {
c, err := Load(write(t, "loadDelay: 300ms\n"))
if err != nil || c.LoadDelay == nil || *c.LoadDelay != 300*time.Millisecond {
t.Errorf("loadDelay = %v, %v", c.LoadDelay, err)
}
if _, err := Load(write(t, "loadDelay: 1m\n")); err == nil {
t.Error("a minute's delay accepted")
}
}
func TestKeybindingLayers(t *testing.T) {
a := write(t, "keybinding:\n lists:\n star: [x]\n note: [N]\n")
b := write(t, "keybinding:\n lists:\n note: [m]\n filter:\n star: [ctrl+x]\n")
c, err := LoadAll([]string{a, b})
if err != nil {
t.Fatal(err)
}
kb := c.Keybinding
if got := kb["lists"]["star"]; len(got) != 1 || got[0] != "x" {
t.Errorf("lists.star from the first file lost: %v", kb["lists"])
}
if got := kb["lists"]["note"]; len(got) != 1 || got[0] != "m" {
t.Errorf("lists.note = %v, want the later file's", got)
}
if got := kb["filter"]["star"]; len(got) != 1 || got[0] != "ctrl+x" {
t.Errorf("filter.star = %v", got)
}
}
+222
View File
@@ -0,0 +1,222 @@
// Package instance lets a lazysecrets:// link go to a lazysecrets that is
// already running, rather than a new window.
//
// Each running lazysecrets listens on a Unix socket of its own in a
// directory only the user can read (Unix sockets work the same on macOS,
// Linux and Windows 10 and later). The link handler asks each one when it
// was last used and whether it can be brought into view, and hands the
// link to the best one, which shows it and comes to the front.
package instance
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
)
// Dir holds the sockets: $XDG_RUNTIME_DIR/lazysecrets if set, else
// run/ under the state directory.
func Dir(stateDir string) string {
if d := os.Getenv("XDG_RUNTIME_DIR"); d != "" {
return filepath.Join(d, "lazysecrets")
}
return filepath.Join(stateDir, "run")
}
// Status is what an instance tells the link handler about itself.
type Status struct {
PID int `json:"pid"`
LastActive time.Time `json:"lastActive"` // last key press or focus
// Reach is how readily it can be shown: 2 in view (outside tmux, or
// in a tmux session a client shows), 1 in a tmux session that a tmux
// client can be switched to, 0 not at all.
Reach int `json:"reach"`
// App is the terminal app it would show in (macOS), preferring the
// app the link was clicked in.
App string `json:"app,omitempty"`
}
type request struct {
Op string `json:"op"` // "status" or "open"
Link string `json:"link,omitempty"`
Front string `json:"front,omitempty"` // app the link was clicked in
}
type response struct {
Status
Err string `json:"err,omitempty"`
}
// Server is the running lazysecrets' end.
type Server struct {
ln net.Listener
path string
open func(link, front string) error // shows the link and comes to the front
mu sync.Mutex
active time.Time
}
// Listen starts listening in dir. open is called, from another goroutine,
// with each link handed over and the app it was clicked in; it should
// return quickly, as the link handler waits for it, and come to the front
// (Raise) afterwards.
func Listen(dir string, open func(link, front string) error) (*Server, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
_ = os.Chmod(dir, 0o700) // a pre-existing directory too
path := filepath.Join(dir, strconv.Itoa(os.Getpid())+".sock")
_ = os.Remove(path) // a crashed process with this PID
ln, err := net.Listen("unix", path)
if err != nil {
return nil, err
}
s := &Server{ln: ln, path: path, open: open, active: time.Now()}
go s.serve()
return s, nil
}
// Touch records that the user just used this instance.
func (s *Server) Touch() {
s.mu.Lock()
s.active = time.Now()
s.mu.Unlock()
}
// Close stops listening and removes the socket.
func (s *Server) Close() error {
err := s.ln.Close()
_ = os.Remove(s.path)
return err
}
func (s *Server) serve() {
for {
c, err := s.ln.Accept()
if err != nil {
return
}
go s.handle(c)
}
}
func (s *Server) handle(c net.Conn) {
defer c.Close()
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
var req request
if err := json.NewDecoder(bufio.NewReader(c)).Decode(&req); err != nil {
return
}
var resp response
switch req.Op {
case "status":
_, app, reach := place(req.Front)
s.mu.Lock()
resp.Status = Status{PID: os.Getpid(), LastActive: s.active, Reach: reach, App: app}
s.mu.Unlock()
case "open":
if err := s.open(req.Link, req.Front); err != nil {
resp.Err = err.Error()
}
default:
resp.Err = fmt.Sprintf("unknown request %q", req.Op)
}
_ = json.NewEncoder(c).Encode(resp)
}
// Found is a running instance.
type Found struct {
Status
path string
}
// Running asks every instance in dir for its status for a link clicked in
// front, removing sockets no one answers on (left by a crash).
func Running(ctx context.Context, dir, front string) []Found {
entries, _ := os.ReadDir(dir)
var (
mu sync.Mutex
out []Found
wg sync.WaitGroup
)
for _, e := range entries {
if !strings.HasSuffix(e.Name(), ".sock") {
continue
}
path := filepath.Join(dir, e.Name())
wg.Add(1)
go func() {
defer wg.Done()
var resp response
err := call(ctx, path, request{Op: "status", Front: front}, &resp)
if errors.Is(err, errGone) {
_ = os.Remove(path)
return
}
if err != nil || resp.Err != "" {
return
}
mu.Lock()
out = append(out, Found{resp.Status, path})
mu.Unlock()
}()
}
wg.Wait()
return out
}
// Best is the instance to hand a link clicked in front to: the most
// recently used of those most readily shown. A link clicked in a terminal
// goes only to one showing in that terminal. ok is false if there's none.
func Best(found []Found, front string, terminal bool) (Found, bool) {
var best Found
for _, f := range found {
if terminal && f.App != front {
continue
}
if f.Reach > best.Reach || (f.Reach == best.Reach && f.LastActive.After(best.LastActive)) {
best = f
}
}
return best, best.Reach > 0
}
// Open hands link, clicked in front, to f.
func Open(ctx context.Context, f Found, link, front string) error {
var resp response
if err := call(ctx, f.path, request{Op: "open", Link: link, Front: front}, &resp); err != nil {
return err
}
if resp.Err != "" {
return errors.New(resp.Err)
}
return nil
}
var errGone = errors.New("no instance listening")
func call(ctx context.Context, path string, req request, resp *response) error {
var d net.Dialer
c, err := d.DialContext(ctx, "unix", path)
if err != nil {
return fmt.Errorf("%w: %v", errGone, err)
}
defer c.Close()
if dl, ok := ctx.Deadline(); ok {
_ = c.SetDeadline(dl)
}
if err := json.NewEncoder(c).Encode(req); err != nil {
return err
}
return json.NewDecoder(c).Decode(resp)
}
+99
View File
@@ -0,0 +1,99 @@
package instance
import (
"context"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// shortDir is a temporary directory with a path short enough for a
// socket (macOS allows 104 bytes).
func shortDir(t *testing.T) string {
d, err := os.MkdirTemp("", "ls")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(d) })
return d
}
func TestHandOver(t *testing.T) {
t.Setenv("TMUX", "")
dir := shortDir(t)
got := make(chan string, 1)
s, err := Listen(dir, func(link, front string) error { got <- link; return nil })
if err != nil {
t.Fatal(err)
}
defer s.Close()
if fi, _ := os.Stat(dir); fi.Mode().Perm() != 0o700 {
t.Errorf("dir mode %v", fi.Mode().Perm())
}
// A socket left by a crashed instance.
stale := filepath.Join(dir, "999999.sock")
ln, err := net.Listen("unix", stale)
if err != nil {
t.Fatal(err)
}
ln.(*net.UnixListener).SetUnlinkOnClose(false)
ln.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
found := Running(ctx, dir, "")
if len(found) != 1 || found[0].PID != os.Getpid() || found[0].Reach != 2 {
t.Fatalf("found %+v", found)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Error("stale socket left behind")
}
best, ok := Best(found, "", false)
if !ok {
t.Fatal("no instance to hand over to")
}
if err := Open(ctx, best, "lazysecrets://azure/kv.vault.azure.net", ""); err != nil {
t.Fatal(err)
}
if l := <-got; l != "lazysecrets://azure/kv.vault.azure.net" {
t.Errorf("got %q", l)
}
}
func TestBest(t *testing.T) {
now := time.Now()
f := func(reach int, ago time.Duration) Found {
return Found{Status: Status{Reach: reach, LastActive: now.Add(-ago), PID: int(ago)}}
}
// In view beats more recently used but needing a tmux switch.
if b, _ := Best([]Found{f(1, time.Second), f(2, time.Hour), f(2, time.Minute)}, "", false); b.PID != int(time.Minute) {
t.Errorf("best = %+v", b)
}
if _, ok := Best([]Found{f(0, 0)}, "", false); ok {
t.Error("handed over to an instance that can't be shown")
}
if _, ok := Best(nil, "", false); ok {
t.Error("best of none")
}
// Clicked in a terminal: only an instance showing there will do,
// however recently another was used.
kitty, dropdown := "/Applications/kitty.app", "/Applications/kitty.app/Contents/kitty-quick-access.app"
inDropdown := f(2, time.Second)
inDropdown.App = dropdown
inKitty := f(1, time.Hour)
inKitty.App = kitty
if b, ok := Best([]Found{inDropdown, inKitty}, kitty, true); !ok || b.App != kitty {
t.Errorf("clicked in kitty: %+v %v", b, ok)
}
if _, ok := Best([]Found{inDropdown}, kitty, true); ok {
t.Error("clicked in kitty, went to the dropdown")
}
// Clicked in Slack: the one used last, wherever.
if b, _ := Best([]Found{inDropdown, inKitty}, "/Applications/Slack.app", false); b.App != dropdown {
t.Errorf("clicked in Slack: %+v", b)
}
}
+123
View File
@@ -0,0 +1,123 @@
package instance
import (
"os"
"os/exec"
"slices"
"strconv"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/linkhandler"
)
// Where an instance shows depends on its terminal app (on macOS): for an
// instance in tmux, the app of a tmux client showing its session, or one
// that could be switched to it. front is the app the link was clicked in;
// clients in it are preferred, so a link clicked in a terminal opens
// there.
// tmux runs tmux on the server this instance runs in.
func tmux(args ...string) (string, error) {
out, err := exec.Command("tmux", args...).Output()
return strings.TrimSpace(string(out)), err
}
func inTmux() bool { return os.Getenv("TMUX") != "" && os.Getenv("TMUX_PANE") != "" }
type client struct {
name, session, activity string // activity: Unix seconds
pid int
app string
}
// clients lists the tmux server's clients, the one last typed in first.
func clients() []client {
out, err := tmux("list-clients", "-F", "#{client_activity} #{client_pid} #{client_name} #{session_name}")
if err != nil {
return nil
}
var cs []client
for _, l := range strings.Split(out, "\n") {
f := strings.SplitN(l, " ", 4)
if len(f) < 4 {
continue
}
pid, err := strconv.Atoi(f[1])
if err != nil {
continue
}
cs = append(cs, client{name: f[2], session: f[3], activity: f[0], pid: pid, app: linkhandler.HostApp(pid)})
}
// Activity has the same width for centuries: strings compare like
// numbers.
slices.SortStableFunc(cs, func(a, b client) int { return strings.Compare(b.activity, a.activity) })
return cs
}
// place is where this instance would show: the tmux client (zero outside
// tmux), the app, and reach: 2 already on screen there (outside tmux, or
// a client on its session), 1 after switching a client, 0 nowhere.
func place(front string) (client, string, int) {
if !inTmux() {
return client{}, linkhandler.HostApp(os.Getpid()), 2
}
session, err := tmux("display-message", "-p", "-t", os.Getenv("TMUX_PANE"), "#{session_name}")
if err != nil {
return client{}, "", 0
}
cs := clients()
// Best first: in the front app on its session, in the front app,
// on its session, any.
rank := func(c client) int {
r := 0
if front != "" && c.app == front {
r += 2
}
if c.session == session {
r++
}
return r
}
var best client
found := false
for _, c := range cs {
if !found || rank(c) > rank(best) {
best, found = c, true
}
}
if !found {
return client{}, "", 0
}
if best.session == session {
return best, best.app, 2
}
return best, best.app, 1
}
// Raise brings this instance into view for a link clicked in front: in
// tmux its window and pane, switching a client to its session if needed;
// then its app to the front, showing a hidden dropdown terminal.
func Raise(front string) {
c, app, reach := place(front)
if reach == 0 {
return
}
if inTmux() {
pane := os.Getenv("TMUX_PANE")
_, _ = tmux("select-window", "-t", pane)
_, _ = tmux("select-pane", "-t", pane)
if reach == 1 {
_, _ = tmux("switch-client", "-c", c.name, "-t", pane)
}
}
switch {
case app == "" || app == front:
// already in front (or unknown)
case linkhandler.IsDropdown(app):
linkhandler.ShowDropdown(app) // it isn't front, so hidden: this shows it
default:
// The bundle, not the name: "kitty" may pick an instance without
// windows, which then opens a new one.
linkhandler.Activate(app)
}
}
+100
View File
@@ -0,0 +1,100 @@
package linkhandler
import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
// macOS apps: where a link was clicked (the frontmost app; the link
// handler runs in the background, so it doesn't change it), and which app
// a process's terminal belongs to. Elsewhere these know nothing.
// App is a running macOS app: its bundle path and process ID.
type App struct {
Bundle string
PID int
}
// FrontApp is the frontmost app.
func FrontApp() App {
if runtime.GOOS != "darwin" {
return App{}
}
asn, err := exec.Command("lsappinfo", "front").Output()
if err != nil {
return App{}
}
out, err := exec.Command("lsappinfo", "info", "-only", "pid", "-only", "bundlepath", strings.TrimSpace(string(asn))).Output()
if err != nil {
return App{}
}
var a App
for _, l := range strings.Split(string(out), "\n") {
k, v, _ := strings.Cut(strings.TrimSpace(l), "=")
switch strings.Trim(k, `"`) {
case "pid":
a.PID, _ = strconv.Atoi(v)
case "LSBundlePath":
a.Bundle = strings.Trim(v, `"`)
}
}
return a
}
// HostApp is the app bundle among pid's ancestors (the terminal it runs
// in), "" if none.
func HostApp(pid int) string {
if runtime.GOOS != "darwin" {
return ""
}
for range 32 {
out, err := exec.Command("ps", "-o", "ppid=,comm=", "-p", strconv.Itoa(pid)).Output()
if err != nil {
return ""
}
ppid, comm, _ := strings.Cut(strings.TrimSpace(string(out)), " ")
// The innermost bundle: kitty's quick access terminal is one
// inside kitty.app.
if i := strings.LastIndex(comm, ".app/Contents/MacOS/"); i >= 0 {
return comm[:i+len(".app")]
}
if pid, err = strconv.Atoi(ppid); err != nil || pid <= 1 {
return ""
}
}
return ""
}
// IsTerminal reports whether bundle is a terminal app.
func IsTerminal(bundle string) bool {
name := strings.ToLower(filepath.Base(bundle))
for _, t := range []string{"kitty", "ghostty", "wezterm", "iterm", "terminal.app", "alacritty", "warp"} {
if strings.Contains(name, t) {
return true
}
}
return false
}
// IsDropdown reports whether bundle is a terminal shown only by a hotkey
// (kitty's quick access terminal): activating it doesn't show it.
func IsDropdown(bundle string) bool { return strings.Contains(bundle, "quick-access") }
// ShowDropdown shows a hidden dropdown terminal (it toggles: call it only
// when the dropdown isn't frontmost).
func ShowDropdown(bundle string) {
if i := strings.Index(bundle, "kitty.app"); i >= 0 {
kitten := bundle[:i+len("kitty.app")] + "/Contents/MacOS/kitten"
_ = exec.Command(kitten, "quick-access-terminal").Start()
}
}
// Activate brings the app to the front.
func Activate(bundle string) {
if runtime.GOOS == "darwin" && bundle != "" {
_ = exec.Command("open", "-a", bundle).Run()
}
}
+141
View File
@@ -0,0 +1,141 @@
package linkhandler
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// On macOS a URL scheme belongs to an app bundle, and links arrive as an
// Apple Event rather than an argument. A small AppleScript applet,
// registered for the scheme, passes them on to "lazysecrets launch".
const bundleID = "no.kebcloud.lazysecrets.links"
// AppPath is where the applet is installed.
func AppPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, "Applications", "Lazysecrets Links.app"), nil
}
const lsregister = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"
// Install registers launch (lazysecrets launch and its flags, the link
// appended) as the handler for lazysecrets:// links. env is set for it,
// since apps start with a minimal environment (no Homebrew on PATH, so no
// az).
func Install(launch []string, env []string) (string, error) {
app, err := AppPath()
if err != nil {
return "", err
}
if err := buildApp(app, launch, env); err != nil {
return "", err
}
if err := run(lsregister, "-f", app); err != nil {
return "", err
}
return app, nil
}
// buildApp writes the applet to app.
func buildApp(app string, launch []string, env []string) error {
cmd := shellJoin(append(append([]string{"/usr/bin/env"}, env...), launch...))
script := fmt.Sprintf(`on open location theURL
do shell script %s & " " & quoted form of theURL & " >/dev/null 2>&1 &"
end open location
on run
display dialog "Lazysecrets Links opens lazysecrets:// links in lazysecrets. It has nothing to show on its own." buttons {"OK"} default button 1 with title "Lazysecrets Links"
end run
`, appleString(cmd))
if err := os.MkdirAll(filepath.Dir(app), 0o755); err != nil {
return err
}
_ = os.RemoveAll(app)
f, err := os.CreateTemp("", "lazysecrets-links-*.applescript")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.WriteString(script); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
src := f.Name()
plist := filepath.Join(app, "Contents", "Info.plist")
steps := [][]string{
{"osacompile", "-o", app, src},
{"plutil", "-replace", "CFBundleIdentifier", "-string", bundleID, plist},
{"plutil", "-replace", "LSUIElement", "-bool", "true", plist}, // no Dock icon
{"plutil", "-replace", exeKey, "-string", launch[0], plist}, // for Check
{"plutil", "-replace", "CFBundleURLTypes", "-json",
`[{"CFBundleURLName":"lazysecrets link","CFBundleURLSchemes":["lazysecrets"]}]`, plist},
// Editing Info.plist breaks osacompile's signature; sign again,
// ad hoc, so macOS runs it.
{"codesign", "--force", "--sign", "-", app},
}
for _, s := range steps {
if err := run(s...); err != nil {
return err
}
}
return nil
}
func run(argv ...string) error {
if out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput(); err != nil {
return fmt.Errorf("%s: %v: %s", filepath.Base(argv[0]), err, strings.TrimSpace(string(out)))
}
return nil
}
// exeKey records, in the applet's Info.plist, the lazysecrets it runs.
const exeKey = "LazysecretsExecutable"
// Check reports why lazysecrets:// links don't open lazysecrets, or "" if
// they do (as far as can be told without opening one).
func Check() string {
app, err := AppPath()
if err != nil {
return ""
}
if _, err := os.Stat(app); err != nil {
return "lazysecrets:// links don't open lazysecrets yet"
}
out, err := exec.Command("plutil", "-extract", exeKey, "raw", filepath.Join(app, "Contents", "Info.plist")).Output()
if err != nil {
return "" // installed before the key was recorded: assume it works
}
if exe := strings.TrimSpace(string(out)); !exists(exe) {
return "lazysecrets:// links run " + exe + ", which no longer exists"
}
return ""
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Uninstall removes the applet and its registration.
func Uninstall() (string, error) {
app, err := AppPath()
if err != nil {
return "", err
}
if _, err := os.Stat(app); os.IsNotExist(err) {
return "", fmt.Errorf("not installed (%s doesn't exist)", app)
}
_ = exec.Command(lsregister, "-u", app).Run()
return app, os.RemoveAll(app)
}
@@ -0,0 +1,53 @@
package linkhandler
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// The applet builds, claims the scheme and passes its signature check. It
// isn't registered, so the system's handler is untouched.
func TestBuildApp(t *testing.T) {
app := filepath.Join(t.TempDir(), "Lazysecrets Links.app")
if err := buildApp(app, []string{"/usr/local/bin/lazysecrets", "launch", "--terminal", "kitty"}, []string{"PATH=/opt/homebrew/bin:/usr/bin"}); err != nil {
t.Fatal(err)
}
plist := filepath.Join(app, "Contents", "Info.plist")
out, err := exec.Command("plutil", "-extract", "CFBundleURLTypes.0.CFBundleURLSchemes.0", "raw", plist).Output()
if err != nil || strings.TrimSpace(string(out)) != "lazysecrets" {
t.Errorf("scheme = %q, %v", out, err)
}
if out, err := exec.Command("codesign", "--verify", "--strict", app).CombinedOutput(); err != nil {
t.Errorf("signature: %v: %s", err, out)
}
src, err := exec.Command("osadecompile", filepath.Join(app, "Contents", "Resources", "Scripts", "main.scpt")).Output()
if err != nil || !strings.Contains(string(src), `'/usr/bin/env' 'PATH=/opt/homebrew/bin:/usr/bin' '/usr/local/bin/lazysecrets' 'launch'`) {
t.Errorf("script:\n%s %v", src, err)
}
}
// Check, against an applet in a temporary home (never registered).
func TestCheck(t *testing.T) {
t.Setenv("HOME", t.TempDir())
if r := Check(); !strings.Contains(r, "don't open lazysecrets yet") {
t.Errorf("nothing installed: %q", r)
}
app, _ := AppPath()
exe := filepath.Join(t.TempDir(), "lazysecrets")
if err := os.WriteFile(exe, nil, 0o755); err != nil {
t.Fatal(err)
}
if err := buildApp(app, []string{exe, "launch"}, nil); err != nil {
t.Fatal(err)
}
if r := Check(); r != "" {
t.Errorf("installed: %q", r)
}
os.Remove(exe)
if r := Check(); !strings.Contains(r, "no longer exists") {
t.Errorf("binary gone: %q", r)
}
}
+108
View File
@@ -0,0 +1,108 @@
package linkhandler
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// On Linux a desktop entry claims the x-scheme-handler/lazysecrets type,
// and xdg-open passes links to it as %u.
const desktopFile = "lazysecrets-links.desktop"
// AppPath is where the desktop entry is installed.
func AppPath() (string, error) {
dir := os.Getenv("XDG_DATA_HOME")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir = filepath.Join(home, ".local", "share")
}
return filepath.Join(dir, "applications", desktopFile), nil
}
// Install registers launch (lazysecrets launch and its flags, the link
// appended) as the handler for lazysecrets:// links, with env set.
func Install(launch []string, env []string) (string, error) {
path, err := AppPath()
if err != nil {
return "", err
}
argv := append(append([]string{"env"}, env...), launch...)
entry := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=Lazysecrets Links
Comment=Opens lazysecrets:// links in lazysecrets
Exec=%s %%u
MimeType=x-scheme-handler/lazysecrets;
NoDisplay=true
Terminal=false
%s=%s
`, desktopExec(argv), exeKey, launch[0])
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(entry), 0o644); err != nil {
return "", err
}
if out, err := exec.Command("xdg-mime", "default", desktopFile, "x-scheme-handler/lazysecrets").CombinedOutput(); err != nil {
return "", fmt.Errorf("xdg-mime: %v: %s", err, strings.TrimSpace(string(out)))
}
_ = exec.Command("update-desktop-database", filepath.Dir(path)).Run() // optional
return path, nil
}
// desktopExec quotes args for a desktop entry's Exec key.
func desktopExec(args []string) string {
q := make([]string, len(args))
for i, a := range args {
a = strings.NewReplacer(`\`, `\\`, `"`, `\"`, "`", "\\`", "$", `\$`, "%", "%%").Replace(a)
q[i] = `"` + a + `"`
}
return strings.Join(q, " ")
}
// exeKey records, in the desktop entry, the lazysecrets it runs.
const exeKey = "X-Lazysecrets-Executable"
// Check reports why lazysecrets:// links don't open lazysecrets, or "" if
// they do (as far as can be told without opening one).
func Check() string {
path, err := AppPath()
if err != nil {
return ""
}
b, err := os.ReadFile(path)
if err != nil {
return "lazysecrets:// links don't open lazysecrets yet"
}
if out, err := exec.Command("xdg-mime", "query", "default", "x-scheme-handler/lazysecrets").Output(); err == nil &&
strings.TrimSpace(string(out)) != desktopFile {
return "lazysecrets:// links open " + strings.TrimSpace(string(out)) + ", not lazysecrets"
}
for _, l := range strings.Split(string(b), "\n") {
if exe, ok := strings.CutPrefix(l, exeKey+"="); ok {
if _, err := os.Stat(exe); err != nil {
return "lazysecrets:// links run " + exe + ", which no longer exists"
}
}
}
return ""
}
// Uninstall removes the desktop entry.
func Uninstall() (string, error) {
path, err := AppPath()
if err != nil {
return "", err
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return "", fmt.Errorf("not installed (%s doesn't exist)", path)
}
return path, os.Remove(path)
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !darwin && !linux
package linkhandler
import (
"errors"
"runtime"
)
var errUnsupported = errors.New("registering lazysecrets:// links isn't supported on " + runtime.GOOS + " yet")
func AppPath() (string, error) { return "", errUnsupported }
func Install(launch []string, env []string) (string, error) { return "", errUnsupported }
func Check() string { return "" } // nothing to set up
func Uninstall() (string, error) { return "", errUnsupported }
+184
View File
@@ -0,0 +1,184 @@
// Package linkhandler makes lazysecrets:// links open lazysecrets: it
// registers the scheme with the operating system, and launches a new
// terminal window running "lazysecrets open <link>" when a link is opened.
//
// The terminal is the one install ran in, detected from its environment,
// unless the config names another or gives a command of its own.
package linkhandler
import (
"fmt"
"os"
"os/exec"
"slices"
"strings"
)
// Terminals lists the terminals launch knows, by name.
var Terminals = []string{"kitty", "ghostty", "wezterm", "iterm2", "terminal", "alacritty", "gnome-terminal", "konsole", "foot"}
// Detect names the terminal from its environment (getenv), or "" if it
// isn't one launch knows. Terminal-specific variables come first: inside
// tmux, TERM_PROGRAM is "tmux" but the terminal's own variables are
// usually inherited.
func Detect(getenv func(string) string) string {
switch {
case getenv("KITTY_WINDOW_ID") != "" || getenv("TERM") == "xterm-kitty":
return "kitty"
case getenv("GHOSTTY_RESOURCES_DIR") != "" || getenv("TERM") == "xterm-ghostty":
return "ghostty"
case getenv("WEZTERM_EXECUTABLE") != "" || getenv("WEZTERM_PANE") != "":
return "wezterm"
case getenv("LC_TERMINAL") == "iTerm2" || getenv("ITERM_SESSION_ID") != "":
return "iterm2"
case getenv("ALACRITTY_WINDOW_ID") != "" || getenv("ALACRITTY_SOCKET") != "":
return "alacritty"
case getenv("KONSOLE_VERSION") != "":
return "konsole"
case getenv("GNOME_TERMINAL_SCREEN") != "":
return "gnome-terminal"
case getenv("TERM") == "foot":
return "foot"
}
switch getenv("TERM_PROGRAM") {
case "iTerm.app":
return "iterm2"
case "Apple_Terminal":
return "terminal"
case "WezTerm":
return "wezterm"
case "ghostty":
return "ghostty"
}
return ""
}
// Placeholder in a custom command stands for the command to run in it.
const Placeholder = "{cmd}"
// Command is the command that opens a new window of terminal running
// inner, for the operating system goos. custom, if set, is used instead,
// with Placeholder replaced by inner's arguments.
func Command(goos, terminal string, custom, inner []string) ([]string, error) {
if len(custom) > 0 {
if !slices.Contains(custom, Placeholder) {
return nil, fmt.Errorf("linkHandler.command must contain %s, where the lazysecrets command goes", Placeholder)
}
var out []string
for _, a := range custom {
if a == Placeholder {
out = append(out, inner...)
} else {
out = append(out, a)
}
}
return out, nil
}
if goos == "darwin" {
// Apps are started through open(1), so they needn't be on PATH.
// -n: a new instance, which opens a window even if one runs.
app := func(name string, args ...string) []string {
return append([]string{"open", "-na", name, "--args"}, append(args, inner...)...)
}
switch terminal {
case "kitty":
// Its own instance, so quit it with the window: kitty
// otherwise stays running without windows.
return app("kitty", "-o", "macos_quit_when_last_window_closed=yes"), nil
case "ghostty":
return app("Ghostty", "-e"), nil
case "wezterm":
return app("WezTerm", "start", "--"), nil
case "alacritty":
return app("Alacritty", "-e"), nil
case "iterm2":
return osascript(`tell application "iTerm"
activate
create window with default profile command %s
end tell`, inner), nil
case "terminal":
return osascript(`tell application "Terminal"
activate
do script %s
end tell`, inner), nil
}
} else {
switch terminal {
case "kitty", "foot":
return append([]string{terminal}, inner...), nil
case "ghostty", "alacritty", "konsole":
return append([]string{terminal, "-e"}, inner...), nil
case "wezterm":
return append([]string{"wezterm", "start", "--"}, inner...), nil
case "gnome-terminal":
return append([]string{"gnome-terminal", "--"}, inner...), nil
}
}
if terminal == "" {
return nil, fmt.Errorf("no terminal set: run lazysecrets handler install from your terminal, or set linkHandler.terminal or linkHandler.command in the config")
}
return nil, fmt.Errorf("unknown terminal %q (known: %s); linkHandler.command runs any other", terminal, strings.Join(Terminals, ", "))
}
// osascript runs an AppleScript with inner, shell-quoted, as a string
// literal in place of %s.
func osascript(script string, inner []string) []string {
return []string{"osascript", "-e", fmt.Sprintf(script, appleString(shellJoin(inner)))}
}
// shellJoin quotes args for a POSIX shell.
func shellJoin(args []string) string {
q := make([]string, len(args))
for i, a := range args {
q[i] = "'" + strings.ReplaceAll(a, "'", `'\''`) + "'"
}
return strings.Join(q, " ")
}
// appleString is s as an AppleScript string literal.
func appleString(s string) string {
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
// Launch opens a new terminal window running inner, without waiting for
// it.
func Launch(argv []string) error {
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil {
return err
}
return cmd.Process.Release()
}
// Executable is the path of the running lazysecrets, to register. A
// binary from "go run" is deleted when it exits, so it's refused.
func Executable() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
if strings.Contains(exe, "go-build") {
return "", fmt.Errorf("this is a temporary binary from go run: build or install lazysecrets first (go install ./cmd/lazysecrets), then run handler install with it")
}
return exe, nil
}
// passEnv lists the variables carried from the install's environment to
// lazysecrets in the new window: apps start with a minimal environment,
// without Homebrew's PATH (so no az) or the user's config locations.
var passEnv = []string{
"PATH", "LAZYSECRETS_CONFIG_FILE", "LAZYSECRETS_ICONS", "XDG_CONFIG_HOME", "XDG_STATE_HOME",
"AZURE_CONFIG_DIR", "DATABRICKS_CONFIG_FILE",
}
// Env returns NAME=value for each variable to carry that is set.
func Env(getenv func(string) string) []string {
var out []string
for _, k := range passEnv {
if v := getenv(k); v != "" {
out = append(out, k+"="+v)
}
}
return out
}
+123
View File
@@ -0,0 +1,123 @@
package linkhandler
import (
"os"
"os/exec"
"slices"
"strings"
"testing"
)
func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
func TestDetect(t *testing.T) {
for _, c := range []struct {
env map[string]string
want string
}{
{map[string]string{"KITTY_WINDOW_ID": "3", "TERM_PROGRAM": "tmux"}, "kitty"}, // kitty, inside tmux
{map[string]string{"TERM_PROGRAM": "ghostty", "GHOSTTY_RESOURCES_DIR": "/x"}, "ghostty"},
{map[string]string{"TERM_PROGRAM": "WezTerm"}, "wezterm"},
{map[string]string{"TERM_PROGRAM": "tmux", "LC_TERMINAL": "iTerm2"}, "iterm2"},
{map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, "terminal"},
{map[string]string{"ALACRITTY_WINDOW_ID": "1"}, "alacritty"},
{map[string]string{"TERM_PROGRAM": "vscode"}, ""},
} {
if got := Detect(env(c.env)); got != c.want {
t.Errorf("Detect(%v) = %q, want %q", c.env, got, c.want)
}
}
}
var inner = []string{"/usr/bin/env", "PATH=/opt/homebrew/bin:/usr/bin", "/usr/local/bin/lazysecrets", "open", "lazysecrets://azure/kv.vault.azure.net/secrets/it's"}
func TestCommandDarwin(t *testing.T) {
got, err := Command("darwin", "kitty", nil, inner)
if err != nil || !slices.Equal(got, append([]string{"open", "-na", "kitty", "--args", "-o", "macos_quit_when_last_window_closed=yes"}, inner...)) {
t.Errorf("kitty: %q %v", got, err)
}
got, _ = Command("darwin", "ghostty", nil, inner)
if !slices.Equal(got[:5], []string{"open", "-na", "Ghostty", "--args", "-e"}) {
t.Errorf("ghostty: %q", got)
}
got, _ = Command("darwin", "iterm2", nil, inner)
if got[0] != "osascript" || !strings.Contains(got[2], `create window with default profile command "'/usr/bin/env'`) {
t.Errorf("iterm2: %q", got)
}
}
// The AppleScript for Terminal.app (and iTerm2, if installed) compiles, with the quote in the link.
func TestAppleScriptCompiles(t *testing.T) {
if _, err := exec.LookPath("osacompile"); err != nil {
t.Skip("no osacompile")
}
terms := []string{"terminal"}
if _, err := os.Stat("/Applications/iTerm.app"); err == nil {
terms = append(terms, "iterm2") // its commands come from the app
}
for _, term := range terms {
argv, _ := Command("darwin", term, nil, inner)
out, err := exec.Command("osacompile", "-o", t.TempDir()+"/x.scpt", "-e", argv[2]).CombinedOutput()
if err != nil {
t.Errorf("%s: %v: %s", term, err, out)
}
}
}
func TestCommandLinux(t *testing.T) {
got, _ := Command("linux", "gnome-terminal", nil, inner)
if !slices.Equal(got, append([]string{"gnome-terminal", "--"}, inner...)) {
t.Errorf("gnome-terminal: %q", got)
}
}
func TestCommandCustom(t *testing.T) {
got, err := Command("linux", "kitty", []string{"foot", "--title", "secrets", Placeholder}, inner)
if err != nil || !slices.Equal(got, append([]string{"foot", "--title", "secrets"}, inner...)) {
t.Errorf("custom: %q %v", got, err)
}
if _, err := Command("linux", "", []string{"foot"}, inner); err == nil {
t.Error("custom command without {cmd} accepted")
}
}
func TestCommandErrors(t *testing.T) {
if _, err := Command("darwin", "", nil, inner); err == nil || !strings.Contains(err.Error(), "handler install") {
t.Errorf("no terminal: %v", err)
}
if _, err := Command("darwin", "xterm2000", nil, inner); err == nil || !strings.Contains(err.Error(), "unknown terminal") {
t.Errorf("unknown terminal: %v", err)
}
}
func TestShellJoinRoundTrip(t *testing.T) {
out, err := exec.Command("sh", "-c", `printf '%s\n' `+shellJoin(inner)).Output()
if err != nil {
t.Fatal(err)
}
if got := strings.Split(strings.TrimSuffix(string(out), "\n"), "\n"); !slices.Equal(got, inner) {
t.Errorf("round trip = %q", got)
}
}
func TestEnv(t *testing.T) {
got := Env(env(map[string]string{"PATH": "/bin", "HOME": "/h", "AZURE_CONFIG_DIR": "/az"}))
if !slices.Equal(got, []string{"PATH=/bin", "AZURE_CONFIG_DIR=/az"}) {
t.Errorf("Env = %q", got)
}
}
func TestIsTerminal(t *testing.T) {
for b, want := range map[string]bool{
"/Applications/kitty.app": true, "/Applications/kitty.app/Contents/kitty-quick-access.app": true,
"/Applications/Ghostty.app": true, "/System/Applications/Utilities/Terminal.app": true,
"/Applications/Slack.app": false, "/Applications/Safari.app": false, "": false,
} {
if IsTerminal(b) != want {
t.Errorf("IsTerminal(%q) = %v", b, !want)
}
}
if !IsDropdown("/Applications/kitty.app/Contents/kitty-quick-access.app") || IsDropdown("/Applications/kitty.app") {
t.Error("IsDropdown")
}
}
+218
View File
@@ -0,0 +1,218 @@
// Package match filters and ranks lists the way fzf (and snacks.nvim's
// picker, a port of it) does: fzf's own scoring, so a match at the start
// of a word or in one run of characters beats letters scattered through a
// name, and fzf's extended search syntax:
//
// prod db both terms, in any order
// db | pw either
// 'prod exactly "prod", not fuzzy
// ^kv- -dev$ starts with / ends with
// !test without "test"
//
// ^ and $ anchor to an item's name, the part of it matches count most
// for (a secret's name rather than the path to it). Lowercase ignores case; an uppercase letter makes that term exact
// about case (smartcase). Results sort by score, then the shorter text,
// then their order in the list.
package match
import (
"slices"
"strings"
"github.com/junegunn/fzf/src/algo"
"github.com/junegunn/fzf/src/util"
)
func init() { algo.Init("default") }
// nameBonus is added to a term's score when all its letters are in the
// item's name rather than in its context (a path, a note), like snacks'
// filename_bonus: "pass" should find db-password before a secret that
// only lives in a vault called "passwords".
const nameBonus = 24
type kind int
const (
fuzzy kind = iota
exact
prefix
suffix
equal
)
type term struct {
kind kind
inverse bool
caseSensitive bool
text []rune
}
// Query is a parsed search: every group must match (AND), a group
// matches when any of its terms does (OR).
type Query struct {
groups [][]term
}
// Parse reads a search in fzf's extended syntax.
func Parse(q string) Query {
var out Query
or := false
for _, tok := range strings.Fields(q) {
if tok == "|" {
or = true
continue
}
t, ok := parseTerm(tok)
if !ok {
continue
}
if n := len(out.groups); or && n > 0 {
out.groups[n-1] = append(out.groups[n-1], t)
} else {
out.groups = append(out.groups, []term{t})
}
or = false
}
return out
}
func parseTerm(tok string) (term, bool) {
t := term{kind: fuzzy}
if s, ok := strings.CutPrefix(tok, "!"); ok {
t.inverse, t.kind, tok = true, exact, s
}
switch {
case strings.HasPrefix(tok, "'"):
t.kind, tok = exact, tok[1:]
case strings.HasPrefix(tok, "^"):
t.kind, tok = prefix, tok[1:]
}
if s, ok := strings.CutSuffix(tok, "$"); ok && s != "" {
if t.kind == prefix {
t.kind = equal
} else {
t.kind = suffix
}
tok = s
}
if tok == "" {
return term{}, false
}
t.caseSensitive = strings.ToLower(tok) != tok
if !t.caseSensitive {
tok = strings.ToLower(tok)
}
t.text = []rune(tok)
return t, true
}
// Empty reports whether the query has no terms: everything matches.
func (q Query) Empty() bool { return len(q.groups) == 0 }
// Item is a row to match: Text is everything searched, and runes
// NameStart up to NameEnd of it are the row's name (the rest is context,
// like a path or a note). Without a name, nothing gets the name bonus.
type Item struct {
Text string
NameStart, NameEnd int
}
// Hit is a matching item: its index in the list, score, and the rune
// positions of the matched letters in its Text.
type Hit struct {
Index int
Score int
Pos []int
}
// Rank returns the items matching q, best first.
func Rank(q Query, items []Item) []Hit {
var hits []Hit
for i, it := range items {
if h, ok := q.match(it); ok {
h.Index = i
hits = append(hits, h)
}
}
slices.SortStableFunc(hits, func(a, b Hit) int {
if a.Score != b.Score {
return b.Score - a.Score
}
return len(items[a.Index].Text) - len(items[b.Index].Text)
})
return hits
}
func (q Query) match(it Item) (Hit, bool) {
chars := util.ToChars([]byte(it.Text))
// ^ and $ anchor to the name: "vaults starting with kv-", not to the
// path before it.
name := chars
if it.NameEnd > 0 {
runes := []rune(it.Text)
name = util.RunesToChars(runes[it.NameStart:min(it.NameEnd, len(runes))])
}
var h Hit
for _, group := range q.groups {
best, matched := 0, false
var bestPos []int
for _, t := range group {
in, offset := &chars, 0
if t.kind == prefix || t.kind == suffix || t.kind == equal {
in, offset = &name, it.NameStart
}
score, pos, ok := t.match(in, offset, it.NameStart, it.NameEnd)
if ok && (!matched || score > best) {
best, bestPos, matched = score, pos, true
}
}
if !matched {
return Hit{}, false
}
h.Score += best
h.Pos = append(h.Pos, bestPos...)
}
slices.Sort(h.Pos)
h.Pos = slices.Compact(h.Pos)
return h, true
}
// match matches t in chars, whose first rune is at offset in the item's
// text: positions are in the text.
func (t term) match(chars *util.Chars, offset, nameStart, nameEnd int) (int, []int, bool) {
fn := algo.FuzzyMatchV2
switch t.kind {
case exact:
fn = algo.ExactMatchNaive
case prefix:
fn = algo.PrefixMatch
case suffix:
fn = algo.SuffixMatch
case equal:
fn = algo.EqualMatch
}
res, pos := fn(t.caseSensitive, false, true, chars, t.text, !t.inverse, nil)
found := res.Start >= 0
if t.inverse {
return 0, nil, !found
}
if !found {
return 0, nil, false
}
var p []int
if pos != nil {
for _, i := range *pos {
p = append(p, i+offset)
}
} else { // prefix, suffix and equal give a range
for i := res.Start; i < res.End; i++ {
p = append(p, i+offset)
}
}
score := res.Score
if len(p) > 0 && slices.Min(p) >= nameStart && slices.Max(p) < nameEnd {
score += nameBonus
}
return score, p, true
}
+107
View File
@@ -0,0 +1,107 @@
package match
import (
"slices"
"testing"
)
func names(items []Item, hits []Hit) []string {
var out []string
for _, h := range hits {
out = append(out, items[h.Index].Text)
}
return out
}
func plain(texts ...string) []Item {
var out []Item
for _, t := range texts {
out = append(out, Item{Text: t, NameEnd: len([]rune(t))})
}
return out
}
func TestRanking(t *testing.T) {
for _, c := range []struct {
query string
items []string
want []string // the order of the hits
}{
// A word that's there beats letters scattered through a name.
{"prod", []string{"p-r-o-d-ucer", "kv-app-prod"}, []string{"kv-app-prod", "p-r-o-d-ucer"}},
// At the start of a word beats inside one.
{"pass", []string{"bypass-token", "db-password"}, []string{"db-password", "bypass-token"}},
// Equal scores: the shorter first.
{"db", []string{"db-password-old-backup", "db-password"}, []string{"db-password", "db-password-old-backup"}},
// Terms in any order, all of them.
{"pass prod", []string{"kv-app-prod/db-password", "kv-app-dev/db-password", "kv-app-prod/api-key"}, []string{"kv-app-prod/db-password"}},
// Either.
{"pass | key", []string{"db-password", "api-key", "storage-account"}, []string{"db-password", "api-key"}},
// Exact, prefix, suffix, without.
{"'prod", []string{"p-r-o-d", "kv-prod"}, []string{"kv-prod"}},
{"^kv-", []string{"old-kv-app", "kv-app"}, []string{"kv-app"}},
{"-dev$", []string{"kv-dev-old", "kv-dev"}, []string{"kv-dev"}},
{"kv !dev", []string{"kv-dev", "kv-prod"}, []string{"kv-prod"}},
// Smartcase.
{"API", []string{"api-key", "API-KEY"}, []string{"API-KEY"}},
{"api", []string{"api-key", "API-KEY"}, []string{"api-key", "API-KEY"}},
} {
items := plain(c.items...)
if got := names(items, Rank(Parse(c.query), items)); !slices.Equal(got, c.want) {
t.Errorf("%q: %v, want %v", c.query, got, c.want)
}
}
}
func TestNameBeatsContext(t *testing.T) {
// "secret · note": a hit in the name beats the same hit in the note.
items := []Item{
{Text: "api-key the password for the api", NameEnd: len("api-key")},
{Text: "sub / vault / password", NameStart: len("sub / vault / "), NameEnd: len("sub / vault / password")},
}
got := names(items, Rank(Parse("password"), items))
if got[0] != "sub / vault / password" {
t.Errorf("%v: the name should win over a note", got)
}
}
func TestPositions(t *testing.T) {
items := plain("kv-app-prød")
hits := Rank(Parse("prød"), items)
if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{7, 8, 9, 10}) {
t.Errorf("%+v: want rune positions 7-10", hits)
}
hits = Rank(Parse("^kv"), items)
if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{0, 1}) {
t.Errorf("prefix: %+v", hits)
}
}
func TestAnchorsOnTheName(t *testing.T) {
path := func(p, name string) Item {
return Item{Text: p + name + " · ops", NameStart: len([]rune(p)), NameEnd: len([]rune(p + name))}
}
items := []Item{
path("󰠅 platform-prod / ", "kv-app-prod"),
path("󰠅 platform-prod / kv-app-prod / ", "db-password"),
path("󰠅 platform-dev / ", "kv-app-dev"),
}
got := names(items, Rank(Parse("^kv- !dev"), items))
if len(got) != 1 || got[0] != items[0].Text {
t.Errorf("^kv- !dev: %v, want just the kv-app-prod vault", got)
}
hits := Rank(Parse("^kv"), items[:1])
start := len([]rune("󰠅 platform-prod / "))
if len(hits) != 1 || !slices.Equal(hits[0].Pos, []int{start, start + 1}) {
t.Errorf("^kv positions %v, want %d-%d", hits, start, start+1)
}
if got := names(items, Rank(Parse("password$"), items)); len(got) != 1 || got[0] != items[1].Text {
t.Errorf("password$: %v", got)
}
}
func TestEmpty(t *testing.T) {
if !Parse(" | ").Empty() || Parse("x").Empty() {
t.Error("Empty")
}
}
+189
View File
@@ -0,0 +1,189 @@
package azure
import (
"bytes"
"cmp"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// account is one az CLI identity in one tenant.
type account struct {
user, tenant string
tenantName string
anchor string // a subscription az maps to this account, to pin it
anchorEnabled bool
isDefault bool
subscriptions int // how many subscriptions az maps to it
}
// azProfilePath is az's account list. It holds no tokens.
func azProfilePath() (string, error) {
dir := os.Getenv("AZURE_CONFIG_DIR")
if dir == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir = filepath.Join(home, ".azure")
}
return filepath.Join(dir, "azureProfile.json"), nil
}
// readAccounts lists the accounts signed in to az, default first.
func readAccounts(path string) ([]account, error) {
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
b = bytes.TrimPrefix(b, []byte("\xef\xbb\xbf")) // az writes a BOM
var profile struct {
Subscriptions []struct {
ID string `json:"id"`
State string `json:"state"`
IsDefault bool `json:"isDefault"`
TenantID string `json:"tenantId"`
TenantName string `json:"tenantDisplayName"`
User struct {
Name string `json:"name"`
} `json:"user"`
} `json:"subscriptions"`
}
if err := json.Unmarshal(b, &profile); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
var out []account
for _, s := range profile.Subscriptions {
if s.User.Name == "" || s.TenantID == "" {
continue
}
i := slices.IndexFunc(out, func(a account) bool { return a.user == s.User.Name && a.tenant == s.TenantID })
if i < 0 {
out = append(out, account{user: s.User.Name, tenant: s.TenantID, tenantName: s.TenantName})
i = len(out) - 1
}
a := &out[i]
a.subscriptions++
// Anchor on az's default subscription if it is this account's,
// otherwise on an enabled one.
enabled := s.State == "Enabled"
switch {
case s.IsDefault:
a.anchor, a.anchorEnabled, a.isDefault = s.ID, enabled, true
case a.isDefault:
case a.anchor == "" || enabled && !a.anchorEnabled:
a.anchor, a.anchorEnabled = s.ID, enabled
}
}
slices.SortStableFunc(out, func(a, b account) int {
if a.isDefault != b.isDefault {
if a.isDefault {
return -1
}
return 1
}
return cmp.Compare(a.user, b.user)
})
return out, nil
}
// accountLabels names accounts as briefly as stays unambiguous: the user
// name before the @, the full user if another user shares that, plus the
// tenant if one user is signed in to several.
func accountLabels(accounts []account) []string {
short := func(u string) string { s, _, _ := strings.Cut(u, "@"); return s }
labels := make([]string, len(accounts))
for i, a := range accounts {
l, tenants := short(a.user), 0
for _, b := range accounts {
if b.user != a.user && short(b.user) == short(a.user) {
l = a.user
}
if b.user == a.user {
tenants++
}
}
if tenants > 1 {
l += " · " + cmp.Or(a.tenantName, a.tenant)
}
labels[i] = l
}
return labels
}
// NewAccounts returns one provider per account signed in to az, the default
// first, or a single provider if AZURE_* configures a service principal or
// az has no accounts yet. Pinned vaults go to the first.
func NewAccounts(s Settings) ([]*Provider, error) {
if os.Getenv("AZURE_CLIENT_ID") != "" && os.Getenv("AZURE_TENANT_ID") != "" {
cred, err := azidentity.NewEnvironmentCredential(nil)
if err != nil {
return nil, err
}
p, err := newWithOptions(authCredential{cred: cred}, options{Settings: s})
if err != nil {
return nil, err
}
return []*Provider{p}, nil
}
path, err := azProfilePath()
if err != nil {
return nil, err
}
accounts, err := readAccounts(path)
if err != nil {
return nil, err
}
if len(accounts) == 0 {
// Not signed in yet: the first call fails with an AuthError and
// the UI offers az login.
cred := authCredential{cred: newCLICredential("", ""), login: []string{"az", "login"}}
p, err := newWithOptions(cred, options{Settings: s})
if err != nil {
return nil, err
}
return []*Provider{p}, nil
}
labels := accountLabels(accounts)
var out []*Provider
for i, a := range accounts {
settings := s
if i > 0 {
settings.Vaults = nil
}
label := ""
if len(accounts) > 1 {
label = string(provider.KindAzure) + " · " + labels[i]
}
cred := authCredential{
cred: newCLICredential(a.anchor, a.tenant),
login: []string{"az", "login", "--tenant", a.tenant},
account: label, // so "sign-in required" says which account
}
p, err := newWithOptions(cred, options{Settings: settings})
if err != nil {
return nil, err
}
if label != "" {
p.id = string(provider.KindAzure) + ":" + a.user + "/" + a.tenant
p.label = label
}
out = append(out, p)
}
return out, nil
}
+121
View File
@@ -0,0 +1,121 @@
package azure
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
)
const profileJSON = "\xef\xbb\xbf" + `{"installationId": "x", "subscriptions": [
{"id": "s-admin-1", "name": "platform", "state": "Enabled", "isDefault": false, "tenantId": "t1", "tenantDisplayName": "Contoso",
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
{"id": "s-admin-2", "name": "old", "state": "Disabled", "isDefault": false, "tenantId": "t1",
"user": {"name": "admin.alice@contoso.onmicrosoft.com", "type": "user"}},
{"id": "s-alice-1", "name": "app-dev", "state": "Enabled", "isDefault": false, "tenantId": "t1",
"user": {"name": "alice@contoso.com", "type": "user"}},
{"id": "s-alice-2", "name": "app-prod", "state": "Enabled", "isDefault": true, "tenantId": "t1",
"user": {"name": "alice@contoso.com", "type": "user"}}
]}`
func TestReadAccounts(t *testing.T) {
path := filepath.Join(t.TempDir(), "azureProfile.json")
if err := os.WriteFile(path, []byte(profileJSON), 0o600); err != nil {
t.Fatal(err)
}
got, err := readAccounts(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("accounts = %+v", got)
}
// Default account first, anchored on the default subscription.
if got[0].user != "alice@contoso.com" || got[0].anchor != "s-alice-2" || !got[0].isDefault {
t.Errorf("default account = %+v", got[0])
}
// Anchored on an enabled subscription, not the disabled one.
if got[1].user != "admin.alice@contoso.onmicrosoft.com" || got[1].anchor != "s-admin-1" || got[1].subscriptions != 2 {
t.Errorf("second account = %+v", got[1])
}
if accts, err := readAccounts(filepath.Join(t.TempDir(), "missing.json")); err != nil || accts != nil {
t.Errorf("missing profile: %v, %v", accts, err)
}
}
func TestAccountLabels(t *testing.T) {
got := accountLabels([]account{
{user: "alice@contoso.com", tenant: "t1"},
{user: "admin@a.example", tenant: "t1"},
{user: "admin@b.example", tenant: "t1"},
{user: "guest@x.example", tenant: "t1", tenantName: "Home"},
{user: "guest@x.example", tenant: "t2", tenantName: "Partner"},
})
want := []string{"alice", "admin@a.example", "admin@b.example", "guest · Home", "guest · Partner"}
if !reflect.DeepEqual(got, want) {
t.Errorf("labels = %q\nwant %q", got, want)
}
}
func TestCLICredentialPinsAccount(t *testing.T) {
var calls [][]string
c := newCLICredential("s-alice-2", "t1")
c.run = func(_ context.Context, args ...string) ([]byte, error) {
calls = append(calls, args)
exp := time.Now().Add(time.Hour).Unix()
return []byte(fmt.Sprintf(`{"accessToken": "tok", "expires_on": %d}`, exp)), nil
}
ctx := context.Background()
vault := policy.TokenRequestOptions{Scopes: []string{"https://vault.azure.net/.default"}}
// Same tenant (Key Vault's challenge names it): pin with --subscription only.
vault.TenantID = "t1"
if tok, err := c.GetToken(ctx, vault); err != nil || tok.Token != "tok" {
t.Fatalf("token: %v, %v", tok, err)
}
if got := strings.Join(calls[0], " "); !strings.Contains(got, "--subscription s-alice-2") || strings.Contains(got, "--tenant") {
t.Errorf("same tenant args: %s", got)
}
// Cached: no second az call.
if _, err := c.GetToken(ctx, vault); err != nil || len(calls) != 1 {
t.Errorf("token not cached: %d calls", len(calls))
}
// Another tenant: --tenant only, since az refuses both.
vault.TenantID = "t2"
if _, err := c.GetToken(ctx, vault); err != nil {
t.Fatal(err)
}
if got := strings.Join(calls[1], " "); !strings.Contains(got, "--tenant t2") || strings.Contains(got, "--subscription") {
t.Errorf("other tenant args: %s", got)
}
}
func TestCLICredentialRenewsAndReportsErrors(t *testing.T) {
n := 0
c := newCLICredential("", "")
c.run = func(context.Context, ...string) ([]byte, error) {
n++
if n == 2 {
return nil, errors.New("AADSTS700082: The refresh token has expired. Please run 'az login'")
}
// Expires inside the refresh margin, so the next call renews.
return []byte(`{"accessToken": "tok", "expiresOn": "` + time.Now().Add(time.Minute).Format("2006-01-02 15:04:05.000000") + `"}`), nil
}
opts := policy.TokenRequestOptions{Scopes: []string{"https://management.azure.com/.default"}}
if _, err := c.GetToken(context.Background(), opts); err != nil {
t.Fatal(err)
}
if _, err := c.GetToken(context.Background(), opts); err == nil || !strings.Contains(err.Error(), "AADSTS700082") {
t.Errorf("err = %v", err)
}
}
+27
View File
@@ -0,0 +1,27 @@
package azure
import (
"context"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// authCredential turns every token failure into a *provider.AuthError, so
// the UI can tell "log in again" apart from other failures however deep in
// the SDK the token was requested.
type authCredential struct {
cred azcore.TokenCredential
login []string
account string // label when there are several accounts
}
func (c authCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
tok, err := c.cred.GetToken(ctx, opts)
if err != nil && ctx.Err() == nil {
err = &provider.AuthError{Kind: provider.KindAzure, Account: c.account, Login: c.login, Err: err}
}
return tok, err
}
+524
View File
@@ -0,0 +1,524 @@
// Package azure is the Azure Key Vault provider. Sources are subscriptions,
// containers are vaults (found via Resource Graph), and objects are secrets,
// keys and certificates.
package azure
import (
"cmp"
"context"
"crypto/x509"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"sync"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
// Provider serves one Azure identity: an az CLI account or a service
// principal. Creating it doesn't contact Azure; authentication problems
// surface as *provider.AuthError from the first call.
type Provider struct {
id, label string // "" for the single-account defaults
cred azcore.TokenCredential
opts options
subs *armsubscriptions.Client
graph *armresourcegraph.Client
mu sync.Mutex
vaults map[string]*vaultClients // by vault URL
tenants map[string]string // tenant ID by subscription ID, for portal links
}
type vaultClients struct {
secrets *azsecrets.Client
keys *azkeys.Client
certs *azcertificates.Client
}
var _ provider.Provider = (*Provider)(nil)
// Settings come from the config file.
type Settings struct {
// HideSubscriptions lists subscriptions to leave out, by name or ID.
HideSubscriptions []string
// Vaults are listed under a "Pinned vaults" source.
Vaults []string
}
// pinnedID identifies the source holding vaults from the config file.
const pinnedID = "pinned-vaults"
// options lets tests point the SDK clients at a local server.
type options struct {
Settings
arm *arm.ClientOptions
client azcore.ClientOptions
skipChallengeValidation bool
}
func newWithOptions(cred azcore.TokenCredential, opts options) (*Provider, error) {
subs, err := armsubscriptions.NewClient(cred, opts.arm)
if err != nil {
return nil, err
}
graph, err := armresourcegraph.NewClient(cred, opts.arm)
if err != nil {
return nil, err
}
return &Provider{
cred: cred, opts: opts, subs: subs, graph: graph,
vaults: map[string]*vaultClients{}, tenants: map[string]string{},
}, nil
}
func (p *Provider) Kind() provider.Kind { return provider.KindAzure }
func (p *Provider) ID() string { return cmp.Or(p.id, string(provider.KindAzure)) }
func (p *Provider) Label() string { return cmp.Or(p.label, string(provider.KindAzure)) }
// Sources lists the subscriptions the signed-in identity can see, skipping
// disabled and deleted ones.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
var out []provider.Source
pager := p.subs.NewListPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
switch deref(s.State) {
case armsubscriptions.SubscriptionStateDisabled, armsubscriptions.SubscriptionStateDeleted:
continue
}
id, tenant := deref(s.SubscriptionID), deref(s.TenantID)
if config.Hidden(p.opts.HideSubscriptions, deref(s.DisplayName), id) {
continue
}
p.mu.Lock()
p.tenants[id] = tenant
p.mu.Unlock()
out = append(out, provider.Source{
Kind: provider.KindAzure,
ID: id,
Name: deref(s.DisplayName),
Detail: "subscription " + id,
URL: PortalResourceURL(tenant, "/subscriptions/"+id, "overview"),
})
}
}
slices.SortFunc(out, func(a, b provider.Source) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
if len(p.opts.Vaults) > 0 {
out = append(out, provider.Source{
Kind: provider.KindAzure, ID: pinnedID, Name: "Pinned vaults", Detail: "from the config file",
})
}
return out, nil
}
// pinnedVaults lists the vaults from the config file. Without a resource ID
// they have no portal link.
func (p *Provider) pinnedVaults(src provider.Source) []provider.Container {
out := make([]provider.Container, 0, len(p.opts.Vaults))
for _, u := range p.opts.Vaults {
if !strings.HasSuffix(u, "/") {
u += "/"
}
name, _, _ := strings.Cut(strings.TrimPrefix(u, "https://"), ".")
out = append(out, provider.Container{Source: src, ID: u, Name: name, Detail: u, Types: objectTypes})
}
return out
}
// Containers finds the subscription's vaults with Resource Graph, falling
// back to the Key Vault management API if the query fails.
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
if src.ID == pinnedID {
return p.pinnedVaults(src), nil
}
vaults, err := p.graphVaults(ctx, src)
if err != nil {
var auth *provider.AuthError
if errors.As(err, &auth) || ctx.Err() != nil {
return nil, err
}
vaults, err = p.armVaults(ctx, src)
if err != nil {
return nil, err
}
}
tenant := p.tenant(src.ID)
for i := range vaults {
vaults[i].URL = PortalResourceURL(tenant, vaults[i].ID, "overview")
}
slices.SortFunc(vaults, func(a, b provider.Container) int {
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return vaults, nil
}
func (p *Provider) tenant(subscriptionID string) string {
p.mu.Lock()
defer p.mu.Unlock()
return p.tenants[subscriptionID]
}
const vaultQuery = `resources
| where type =~ 'microsoft.keyvault/vaults'
| project id, name, uri = tostring(properties.vaultUri)`
func (p *Provider) graphVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
var out []provider.Container
req := armresourcegraph.QueryRequest{
Query: to.Ptr(vaultQuery),
Subscriptions: []*string{to.Ptr(src.ID)},
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
}
for {
resp, err := p.graph.Resources(ctx, req, nil)
if err != nil {
return nil, cleanError(err)
}
rows, ok := resp.Data.([]any)
if !ok {
return nil, fmt.Errorf("resource graph: unexpected result %T", resp.Data)
}
for _, r := range rows {
if c, ok := graphVault(src, r); ok {
out = append(out, c)
}
}
if resp.SkipToken == nil || *resp.SkipToken == "" {
return out, nil
}
req.Options.SkipToken = resp.SkipToken
}
}
func graphVault(src provider.Source, row any) (provider.Container, bool) {
m, ok := row.(map[string]any)
if !ok {
return provider.Container{}, false
}
id, _ := m["id"].(string)
name, _ := m["name"].(string)
uri, _ := m["uri"].(string)
if id == "" || name == "" || uri == "" {
return provider.Container{}, false
}
return provider.Container{Source: src, ID: id, Name: name, Detail: uri, Types: objectTypes}, true
}
func (p *Provider) armVaults(ctx context.Context, src provider.Source) ([]provider.Container, error) {
client, err := armkeyvault.NewVaultsClient(src.ID, p.cred, p.opts.arm)
if err != nil {
return nil, err
}
var out []provider.Container
pager := client.NewListBySubscriptionPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, v := range page.Value {
if v.Properties == nil || v.Properties.VaultURI == nil {
continue
}
out = append(out, provider.Container{
Source: src, ID: deref(v.ID), Name: deref(v.Name),
Detail: *v.Properties.VaultURI, Types: objectTypes,
})
}
}
return out, nil
}
func (p *Provider) clients(vaultURL string) (*vaultClients, error) {
p.mu.Lock()
defer p.mu.Unlock()
if c, ok := p.vaults[vaultURL]; ok {
return c, nil
}
skip := p.opts.skipChallengeValidation
s, err := azsecrets.NewClient(vaultURL, p.cred, &azsecrets.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
k, err := azkeys.NewClient(vaultURL, p.cred, &azkeys.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
c, err := azcertificates.NewClient(vaultURL, p.cred, &azcertificates.ClientOptions{
ClientOptions: p.opts.client, DisableChallengeResourceVerification: skip,
})
if err != nil {
return nil, err
}
vc := &vaultClients{secrets: s, keys: k, certs: c}
p.vaults[vaultURL] = vc
return vc, nil
}
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
vc, err := p.clients(c.Detail)
if err != nil {
return nil, err
}
var out []provider.Object
switch t {
case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
// Certificate-backing secrets appear under Certificates.
if s.ID == nil || deref(s.Managed) {
continue
}
out = append(out, secretObject(c, s))
}
}
case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID == nil || deref(k.Managed) {
continue
}
out = append(out, keyObject(c, k))
}
}
case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesPager(nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID == nil {
continue
}
out = append(out, certObject(c, cp))
}
}
default:
return nil, provider.ErrUnsupported
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil
}
// Describe fetches key and certificate details. Secrets are returned as-is:
// the only way to get more is GetSecret, which returns the value.
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return o, err
}
switch o.Type {
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return o, cleanError(err)
}
if resp.Key != nil {
if resp.Key.KID != nil {
o.Version = resp.Key.KID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Key", string(*resp.Key.KID))
}
o.Attrs = keyAttrs(resp.Key)
}
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return o, cleanError(err)
}
if resp.ID != nil {
o.Version = resp.ID.Version()
o.URL = portalAssetURL(p.tenant(o.Container.Source.ID), "Certificate", string(*resp.ID))
}
if pol := resp.Policy; pol != nil && pol.SecretProperties != nil {
o.ContentType = deref(pol.SecretProperties.ContentType)
}
if cert, err := x509.ParseCertificate(resp.CER); err == nil {
o.Attrs = certAttrs(cert, resp.X509Thumbprint)
}
}
return o, nil
}
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return nil, err
}
var out []provider.Object
switch o.Type {
case provider.TypeSecret:
pager := vc.secrets.NewListSecretPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, s := range page.Value {
if s.ID != nil {
out = append(out, secretObject(o.Container, s))
}
}
}
case provider.TypeKey:
pager := vc.keys.NewListKeyPropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, k := range page.Value {
if k.KID != nil {
out = append(out, keyObject(o.Container, k))
}
}
}
case provider.TypeCertificate:
pager := vc.certs.NewListCertificatePropertiesVersionsPager(o.Name, nil)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, cleanError(err)
}
for _, cp := range page.Value {
if cp.ID != nil {
out = append(out, certObject(o.Container, cp))
}
}
}
default:
return nil, provider.ErrUnsupported
}
for i := range out {
out[i].Reference = versionedReference(out[i])
}
slices.SortFunc(out, func(a, b provider.Object) int { return b.Updated.Compare(a.Updated) })
return out, nil
}
// versionedReference pins an object's reference to its version, e.g.
// @Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/name/v1).
func versionedReference(o provider.Object) string {
if o.Version == "" {
return o.Reference
}
if ref, ok := strings.CutSuffix(o.Reference, ")"); ok && o.Type == provider.TypeSecret {
return ref + "/" + o.Version + ")"
}
return o.Reference + "/" + o.Version
}
// Value returns a secret's value, a key's public key or a certificate's
// public certificate, as of o.Version (latest if empty).
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
vc, err := p.clients(o.Container.Detail)
if err != nil {
return provider.SecretValue{}, err
}
switch o.Type {
case provider.TypeSecret:
resp, err := vc.secrets.GetSecret(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
return provider.NewSecretValue([]byte(deref(resp.Value))), nil
case provider.TypeKey:
resp, err := vc.keys.GetKey(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
pemKey, err := publicKeyPEM(resp.Key)
if err != nil {
return provider.SecretValue{}, err
}
return provider.NewSecretValue([]byte(pemKey)), nil
case provider.TypeCertificate:
resp, err := vc.certs.GetCertificate(ctx, o.Name, o.Version, nil)
if err != nil {
return provider.SecretValue{}, cleanError(err)
}
if len(resp.CER) == 0 {
return provider.SecretValue{}, errors.New("certificate has no public certificate yet (pending issuance?)")
}
return provider.NewSecretValue([]byte(certPEM(resp.CER))), nil
}
return provider.SecretValue{}, provider.ErrUnsupported
}
var _ provider.Locator = (*Provider)(nil)
// validHost guards the Resource Graph query against odd input.
var validHost = regexp.MustCompile(`^[a-z0-9.-]+$`)
// Locate finds the subscription of the vault with the given host: a pinned
// vault, or one Resource Graph query across the account's subscriptions.
// Vaults in hidden subscriptions aren't found.
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
host = strings.ToLower(host)
if !validHost.MatchString(host) {
return "", false, fmt.Errorf("not a vault host: %q", host)
}
url := "https://" + host + "/"
for _, v := range p.opts.Vaults {
if strings.EqualFold(strings.TrimSuffix(v, "/")+"/", url) {
return pinnedID, true, nil
}
}
resp, err := p.graph.Resources(ctx, armresourcegraph.QueryRequest{
Query: to.Ptr(`resources
| where type =~ 'microsoft.keyvault/vaults' and tostring(properties.vaultUri) =~ '` + url + `'
| project subscriptionId`),
Options: &armresourcegraph.QueryRequestOptions{ResultFormat: to.Ptr(armresourcegraph.ResultFormatObjectArray)},
}, nil)
if err != nil {
return "", false, cleanError(err)
}
rows, _ := resp.Data.([]any)
for _, r := range rows {
m, _ := r.(map[string]any)
sub, _ := m["subscriptionId"].(string)
if sub != "" && !config.Hidden(p.opts.HideSubscriptions, "", sub) {
return sub, true, nil
}
}
return "", false, nil
}
+419
View File
@@ -0,0 +1,419 @@
package azure
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha1"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"math/big"
"net"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
type staticCred struct{ err error }
func (c staticCred) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) {
if c.err != nil {
return azcore.AccessToken{}, c.err
}
return azcore.AccessToken{Token: "token", ExpiresOn: time.Now().Add(time.Hour)}, nil
}
// fakeAzure serves just enough of ARM and the Key Vault data plane over TLS,
// including Key Vault's 401 auth challenge.
func fakeAzure(t *testing.T, cred azcore.TokenCredential, handler http.HandlerFunc) (*Provider, *httptest.Server) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
w.Header().Set("WWW-Authenticate",
`Bearer authorization="https://login.microsoftonline.com/tenant", resource="https://vault.azure.net"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
handler(w, r)
}))
t.Cleanup(srv.Close)
client := azcore.ClientOptions{
Transport: srv.Client(),
Retry: policy.RetryOptions{MaxRetries: -1},
Cloud: cloud.Configuration{
ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/",
Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
cloud.ResourceManager: {Endpoint: srv.URL, Audience: "https://management.azure.com"},
},
},
}
p, err := newWithOptions(cred, options{
arm: &arm.ClientOptions{ClientOptions: client},
client: client,
skipChallengeValidation: true,
})
if err != nil {
t.Fatal(err)
}
return p, srv
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func vault(srv *httptest.Server) provider.Container {
return provider.Container{ID: "/subscriptions/s/vault", Name: "kv", Detail: srv.URL + "/", Types: objectTypes}
}
func TestTokenFailureIsAuthError(t *testing.T) {
login := []string{"az", "login"}
cred := authCredential{cred: staticCred{err: errors.New("AADSTS700082: refresh token expired")}, login: login}
p, srv := fakeAzure(t, cred, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("request sent without a token: %s", r.URL)
})
check := func(name string, err error) {
t.Helper()
var auth *provider.AuthError
if !errors.As(err, &auth) {
t.Fatalf("%s: error %v is not an AuthError", name, err)
}
if !reflect.DeepEqual(auth.Login, login) || !strings.Contains(auth.Error(), "AADSTS700082") {
t.Errorf("%s: unexpected AuthError %+v", name, auth)
}
}
_, err := p.Sources(context.Background())
check("Sources (ARM)", err)
_, err = p.Objects(context.Background(), vault(srv), provider.TypeSecret)
check("Objects (Key Vault)", err)
_, err = p.Containers(context.Background(), provider.Source{ID: "s"})
check("Containers (no ARM fallback on auth errors)", err)
}
func TestAuthErrorNamesAccount(t *testing.T) {
cred := authCredential{cred: staticCred{err: errors.New("AADSTS53003")}, account: "Azure · admin"}
_, err := cred.GetToken(context.Background(), policy.TokenRequestOptions{Scopes: []string{"x"}})
var auth *provider.AuthError
if !errors.As(err, &auth) || auth.Who() != "Azure · admin" || !strings.HasPrefix(auth.Error(), "Azure · admin:") {
t.Errorf("err = %v", err)
}
}
func TestListSecretsSkipsManagedAndPages(t *testing.T) {
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/secrets" && r.URL.Query().Get("page") == "":
writeJSON(w, 200, map[string]any{
"value": []any{
map[string]any{"id": srvURL + "/secrets/b-secret", "contentType": "text/plain",
"attributes": map[string]any{"enabled": true, "updated": 1767225600}, "tags": map[string]any{"env": "prod"}},
map[string]any{"id": srvURL + "/secrets/tls-cert", "managed": true},
},
"nextLink": srvURL + "/secrets?page=2&api-version=" + r.URL.Query().Get("api-version"),
})
case r.URL.Path == "/secrets":
writeJSON(w, 200, map[string]any{"value": []any{map[string]any{"id": srvURL + "/secrets/a-secret"}}})
default:
http.NotFound(w, r)
}
})
srvURL = srv.URL
got, err := p.Objects(context.Background(), vault(srv), provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
var names []string
for _, o := range got {
names = append(names, o.Name)
}
if !reflect.DeepEqual(names, []string{"a-secret", "b-secret"}) {
t.Fatalf("names = %v, want sorted and without the managed secret", names)
}
b := got[1]
if b.ContentType != "text/plain" || b.Tags["env"] != "prod" || b.Enabled == nil || !*b.Enabled ||
b.Updated.IsZero() || b.Type != provider.TypeSecret {
t.Errorf("metadata not mapped: %+v", b)
}
if want := "@Microsoft.KeyVault(SecretUri=" + srv.URL + "/secrets/b-secret)"; b.Reference != want {
t.Errorf("Reference = %q, want %q", b.Reference, want)
}
}
func TestForbiddenIsShortened(t *testing.T) {
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 403, map[string]any{"error": map[string]any{
"code": "Forbidden", "message": "Caller is not authorized to perform action on resource.",
}})
})
_, err := p.Objects(context.Background(), vault(srv), provider.TypeKey)
if err == nil {
t.Fatal("expected error")
}
if got, want := err.Error(), "403 Forbidden: Caller is not authorized to perform action on resource."; got != want {
t.Errorf("error = %q, want %q", got, want)
}
var re *azcore.ResponseError
if !errors.As(err, &re) || re.StatusCode != 403 {
t.Error("original ResponseError lost from chain")
}
}
func TestShortenDenied(t *testing.T) {
msg := "Caller is not authorized to perform action on resource.\r\nIf role assignments, deny assignments or role definitions were changed recently, please observe propagation time.\r\nCaller: appid=04b07795;oid=1234\r\nAction: 'Microsoft.KeyVault/vaults/secrets/readMetadata/action'\r\nResource: '/subscriptions/x/vaults/kv'"
want := "Caller is not authorized to perform action on resource; missing Microsoft.KeyVault/vaults/secrets/readMetadata/action"
for _, variant := range []string{msg, strings.ReplaceAll(msg, "\r\n", " ")} {
if got := shortenDenied(variant); got != want {
t.Errorf("got %q", got)
}
}
if got := shortenDenied("Public network access is disabled."); got != "Public network access is disabled." {
t.Errorf("unrelated message changed: %q", got)
}
}
func TestSecretValue(t *testing.T) {
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/secrets/db-password/v2" && r.URL.Path != "/secrets/db-password/" {
http.NotFound(w, r)
return
}
writeJSON(w, 200, map[string]any{"id": srvURL + r.URL.Path, "value": "value-of-" + r.URL.Path})
})
srvURL = srv.URL
o := provider.Object{Container: vault(srv), Type: provider.TypeSecret, Name: "db-password"}
for version, want := range map[string]string{"": "value-of-/secrets/db-password/", "v2": "value-of-/secrets/db-password/v2"} {
o.Version = version
v, err := p.Value(context.Background(), o)
if err != nil {
t.Fatal(err)
}
if v.Reveal() != want {
t.Errorf("version %q: got %q", version, v.Reveal())
}
}
}
func TestDescribeCertificate(t *testing.T) {
der, cert := selfSigned(t)
thumb := sha1.Sum(der)
var srvURL string
p, srv := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"id": srvURL + "/certificates/app-tls/abc123",
"cer": base64.StdEncoding.EncodeToString(der),
"x5t": base64.RawURLEncoding.EncodeToString(thumb[:]),
"policy": map[string]any{"secret_props": map[string]any{"contentType": "application/x-pem-file"}},
})
})
srvURL = srv.URL
o, err := p.Describe(context.Background(), provider.Object{Container: vault(srv), Type: provider.TypeCertificate, Name: "app-tls"})
if err != nil {
t.Fatal(err)
}
attrs := map[string]string{}
for _, a := range o.Attrs {
attrs[a.Label] = a.Value
}
if o.Version != "abc123" || o.ContentType != "application/x-pem-file" {
t.Errorf("version/content type = %q/%q", o.Version, o.ContentType)
}
if attrs["subject"] != "CN=app.example.com" || attrs["SANs"] != "app.example.com, 10.0.0.1" ||
!strings.HasPrefix(attrs["thumbprint"], hexColon(thumb[:2])) {
t.Errorf("attrs = %v", attrs)
}
v, err := p.Value(context.Background(), o)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(v.Reveal()))
if block == nil || block.Type != "CERTIFICATE" || !cert.Equal(mustParse(t, block.Bytes)) {
t.Errorf("certificate PEM does not round-trip: %q", v.Reveal())
}
}
func TestPublicKeyPEM(t *testing.T) {
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
ecKey, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
ecX, ecY := ecPoint(t, ecKey)
for name, tc := range map[string]struct {
jwk azkeys.JSONWebKey
pub any
wantDesc string
}{
"rsa": {
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeRSAHSM), N: rsaKey.N.Bytes(),
E: big.NewInt(int64(rsaKey.E)).Bytes(), KeyOps: []*azkeys.KeyOperation{to.Ptr(azkeys.KeyOperationSign)}},
pub: &rsaKey.PublicKey, wantDesc: "RSA-HSM 2048",
},
"ec": {
jwk: azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeEC), Crv: to.Ptr(azkeys.CurveNameP384), X: ecX, Y: ecY},
pub: &ecKey.PublicKey, wantDesc: "EC P-384",
},
} {
t.Run(name, func(t *testing.T) {
out, err := publicKeyPEM(&tc.jwk)
if err != nil {
t.Fatal(err)
}
block, _ := pem.Decode([]byte(out))
got, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
t.Fatal(err)
}
if !got.(interface{ Equal(crypto.PublicKey) bool }).Equal(tc.pub) {
t.Error("public key does not round-trip")
}
if desc := keyAttrs(&tc.jwk)[0].Value; desc != tc.wantDesc {
t.Errorf("key type = %q, want %q", desc, tc.wantDesc)
}
})
}
if _, err := publicKeyPEM(&azkeys.JSONWebKey{Kty: to.Ptr(azkeys.KeyTypeOct)}); err == nil {
t.Error("symmetric key should have no public key")
}
}
func TestPortalURLs(t *testing.T) {
id := "/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv"
c := provider.Container{URL: PortalResourceURL("t1", id, "overview")}
if want := "https://portal.azure.com/#@t1/resource" + id + "/overview"; c.URL != want {
t.Errorf("vault URL = %q", c.URL)
}
if got := ObjectListURL(c, provider.TypeCertificate); got != "https://portal.azure.com/#@t1/resource"+id+"/certificates" {
t.Errorf("certificates URL = %q", got)
}
if got := ObjectListURL(provider.Container{}, provider.TypeSecret); got != "" {
t.Errorf("URL without vault page = %q", got)
}
if got := PortalResourceURL("", "", "overview"); got != "" {
t.Errorf("URL without resource = %q", got)
}
if got := portalAssetURL("", "Key", "https://kv.vault.azure.net/keys/k/v1"); got !=
"https://portal.azure.com/#asset/Microsoft_Azure_KeyVault/Key/https://kv.vault.azure.net/keys/k/v1" {
t.Errorf("asset URL = %q", got)
}
}
func TestVersionedReference(t *testing.T) {
sec := provider.Object{Type: provider.TypeSecret, Version: "v1",
Reference: "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s)"}
if got := versionedReference(sec); got != "@Microsoft.KeyVault(SecretUri=https://kv.vault.azure.net/secrets/s/v1)" {
t.Errorf("secret: %q", got)
}
key := provider.Object{Type: provider.TypeKey, Version: "v2", Reference: "https://kv.vault.azure.net/keys/k"}
if got := versionedReference(key); got != "https://kv.vault.azure.net/keys/k/v2" {
t.Errorf("key: %q", got)
}
}
func TestHiddenSubscriptionsAndPinnedVaults(t *testing.T) {
p, _ := fakeAzure(t, staticCred{}, func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"value": []any{
map[string]any{"subscriptionId": "s1", "displayName": "keep-me", "state": "Enabled", "tenantId": "t"},
map[string]any{"subscriptionId": "s2", "displayName": "platform-mgmt", "state": "Enabled", "tenantId": "t"},
map[string]any{"subscriptionId": "s3", "displayName": "by-id", "state": "Enabled", "tenantId": "t"},
}})
})
p.opts.Settings = Settings{
HideSubscriptions: []string{"PLATFORM-MGMT", "s3"},
Vaults: []string{"https://kv-pinned.vault.azure.net"},
}
srcs, err := p.Sources(context.Background())
if err != nil {
t.Fatal(err)
}
var names []string
for _, s := range srcs {
names = append(names, s.Name)
}
if !reflect.DeepEqual(names, []string{"keep-me", "Pinned vaults"}) {
t.Fatalf("sources = %v", names)
}
vaults, err := p.Containers(context.Background(), srcs[1])
if err != nil {
t.Fatal(err)
}
if len(vaults) != 1 || vaults[0].Name != "kv-pinned" || vaults[0].Detail != "https://kv-pinned.vault.azure.net/" ||
len(vaults[0].Types) != 3 {
t.Errorf("pinned vaults = %+v", vaults)
}
}
func TestGraphVault(t *testing.T) {
src := provider.Source{ID: "sub"}
c, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv", "uri": "https://kv.vault.azure.net/"})
if !ok || c.Name != "kv" || c.Detail != "https://kv.vault.azure.net/" || len(c.Types) != 3 {
t.Errorf("graphVault = %+v, %v", c, ok)
}
if _, ok := graphVault(src, map[string]any{"id": "/x/kv", "name": "kv"}); ok {
t.Error("row without uri accepted")
}
}
func selfSigned(t *testing.T) ([]byte, *x509.Certificate) {
t.Helper()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "app.example.com"},
DNSNames: []string{"app.example.com"},
IPAddresses: []net.IP{net.ParseIP("10.0.0.1")},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
return der, mustParse(t, der)
}
func mustParse(t *testing.T, der []byte) *x509.Certificate {
t.Helper()
c, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
return c
}
func ecPoint(t *testing.T, k *ecdsa.PrivateKey) (x, y []byte) {
t.Helper()
b, err := k.PublicKey.Bytes()
if err != nil {
t.Fatal(err)
}
size := (len(b) - 1) / 2
return b[1 : 1+size], b[1+size:]
}
+123
View File
@@ -0,0 +1,123 @@
package azure
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"sync"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
)
// cliCredential gets tokens from the az CLI for one signed-in account.
//
// az can hold several accounts, but `az account get-access-token` can't be
// told which user to use: it picks the account a subscription belongs to.
// So each account is pinned with --subscription. az refuses --subscription
// together with --tenant, which the SDK's AzureCLICredential would pass for
// every Key Vault request (the vault's auth challenge names a tenant), so
// this sends --tenant only for vaults in another tenant.
//
// Tokens are cached in memory until shortly before they expire, so the
// many SDK clients don't each start an az process.
type cliCredential struct {
subscription string // pins the account; "" for az's default account
tenant string // the account's tenant, if known
run func(ctx context.Context, args ...string) ([]byte, error)
mu sync.Mutex
cache map[string]azcore.AccessToken // by resource and tenant argument
}
func newCLICredential(subscription, tenant string) *cliCredential {
return &cliCredential{subscription: subscription, tenant: tenant, run: runAz, cache: map[string]azcore.AccessToken{}}
}
// refreshMargin renews tokens this long before they expire.
const refreshMargin = 5 * time.Minute
func (c *cliCredential) GetToken(ctx context.Context, opts policy.TokenRequestOptions) (azcore.AccessToken, error) {
if len(opts.Scopes) != 1 {
return azcore.AccessToken{}, errors.New("az: exactly one scope is supported")
}
if opts.Claims != "" {
return azcore.AccessToken{}, errors.New("az: this request needs multifactor authentication; run az login again")
}
resource := strings.TrimSuffix(opts.Scopes[0], "/.default")
args := []string{"account", "get-access-token", "--output", "json", "--resource", resource}
switch {
case opts.TenantID != "" && opts.TenantID != c.tenant:
// Another tenant: az picks the account itself.
args = append(args, "--tenant", opts.TenantID)
case c.subscription != "":
args = append(args, "--subscription", c.subscription)
}
key := strings.Join(args[5:], " ")
c.mu.Lock()
defer c.mu.Unlock()
if tok, ok := c.cache[key]; ok && time.Until(tok.ExpiresOn) > refreshMargin {
return tok, nil
}
out, err := c.run(ctx, args...)
if err != nil {
return azcore.AccessToken{}, err
}
tok, err := parseCLIToken(out)
if err != nil {
return azcore.AccessToken{}, err
}
c.cache[key] = tok
return tok, nil
}
func parseCLIToken(b []byte) (azcore.AccessToken, error) {
var t struct {
AccessToken string `json:"accessToken"`
ExpiresOn int64 `json:"expires_on"` // az 2.54+
ExpiresOnS string `json:"expiresOn"` // older az: local time
}
if err := json.Unmarshal(b, &t); err != nil {
return azcore.AccessToken{}, fmt.Errorf("az: unexpected output: %w", err)
}
if t.AccessToken == "" {
return azcore.AccessToken{}, errors.New("az: no access token in output")
}
exp := time.Unix(t.ExpiresOn, 0)
if t.ExpiresOn == 0 {
var err error
exp, err = time.ParseInLocation("2006-01-02 15:04:05.999999", t.ExpiresOnS, time.Local)
if err != nil {
return azcore.AccessToken{}, fmt.Errorf("az: unexpected expiry %q", t.ExpiresOnS)
}
}
return azcore.AccessToken{Token: t.AccessToken, ExpiresOn: exp}, nil
}
// azTimeout bounds one az call; a hung az would otherwise block a pane.
const azTimeout = 30 * time.Second
func runAz(ctx context.Context, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, azTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "az", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if errors.Is(err, exec.ErrNotFound) {
return nil, errors.New("az CLI not found; install it or set AZURE_* for a service principal")
}
if err != nil {
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return nil, errors.New(msg)
}
return nil, err
}
return out, nil
}
+320
View File
@@ -0,0 +1,320 @@
package azure
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net/http"
"strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azcertificates"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func deref[T any](p *T) T {
var zero T
if p == nil {
return zero
}
return *p
}
func tags(m map[string]*string) map[string]string {
if len(m) == 0 {
return nil
}
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = deref(v)
}
return out
}
const portal = "https://portal.azure.com/#"
// PortalResourceURL links to a blade of an Azure resource in the portal,
// e.g. "overview" or "secrets". tenant may be empty.
func PortalResourceURL(tenant, resourceID, blade string) string {
if resourceID == "" {
return ""
}
return portal + tenantPrefix(tenant) + "resource" + resourceID + "/" + blade
}
// portalAssetURL links to a Key Vault object's page; objectID must include
// the version.
func portalAssetURL(tenant, kind, objectID string) string {
return portal + tenantPrefix(tenant) + "asset/Microsoft_Azure_KeyVault/" + kind + "/" + objectID
}
func tenantPrefix(tenant string) string {
if tenant == "" {
return ""
}
return "@" + tenant + "/"
}
// ObjectListURL links to the vault's list of the given object type,
// derived from the container's overview URL.
func ObjectListURL(c provider.Container, t provider.ObjectType) string {
base, ok := strings.CutSuffix(c.URL, "/overview")
if !ok {
return ""
}
switch t {
case provider.TypeKey:
return base + "/keys"
case provider.TypeCertificate:
return base + "/certificates"
}
return base + "/secrets"
}
// objectURL is the data-plane URL of an object, e.g.
// https://kv.vault.azure.net/secrets/name.
func objectURL(c provider.Container, collection, name string) string {
return strings.TrimSuffix(c.Detail, "/") + "/" + collection + "/" + name
}
func secretObject(c provider.Container, p *azsecrets.SecretProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, "secrets", name)
o := provider.Object{
Container: c,
Type: provider.TypeSecret,
ID: url,
Name: name,
Version: p.ID.Version(),
ContentType: deref(p.ContentType),
Tags: tags(p.Tags),
Reference: "@Microsoft.KeyVault(SecretUri=" + url + ")",
URL: ObjectListURL(c, provider.TypeSecret),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
return o
}
func keyObject(c provider.Container, p *azkeys.KeyProperties) provider.Object {
name := p.KID.Name()
url := objectURL(c, "keys", name)
o := provider.Object{
Container: c,
Type: provider.TypeKey,
ID: url,
Name: name,
Version: p.KID.Version(),
Tags: tags(p.Tags),
Reference: url,
URL: ObjectListURL(c, provider.TypeKey),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
return o
}
func certObject(c provider.Container, p *azcertificates.CertificateProperties) provider.Object {
name := p.ID.Name()
url := objectURL(c, "certificates", name)
o := provider.Object{
Container: c,
Type: provider.TypeCertificate,
ID: url,
Name: name,
Version: p.ID.Version(),
Tags: tags(p.Tags),
Reference: url,
URL: ObjectListURL(c, provider.TypeCertificate),
}
if a := p.Attributes; a != nil {
o.Enabled, o.Updated, o.Expires = a.Enabled, deref(a.Updated), deref(a.Expires)
}
if len(p.X509Thumbprint) > 0 {
o.Attrs = []provider.Attr{{Label: "thumbprint", Value: hexColon(p.X509Thumbprint)}}
}
return o
}
// keyAttrs describes a JSON Web Key, e.g. "RSA-HSM 3072" or "EC P-256".
func keyAttrs(k *azkeys.JSONWebKey) []provider.Attr {
if k == nil {
return nil
}
kty := string(deref(k.Kty))
desc := kty
switch {
case len(k.N) > 0:
desc = fmt.Sprintf("%s %d", kty, new(big.Int).SetBytes(k.N).BitLen())
case k.Crv != nil:
desc = kty + " " + string(*k.Crv)
}
attrs := []provider.Attr{{Label: "key type", Value: desc}}
var ops []string
for _, op := range k.KeyOps {
ops = append(ops, string(deref(op)))
}
if len(ops) > 0 {
attrs = append(attrs, provider.Attr{Label: "ops", Value: strings.Join(ops, ", ")})
}
return attrs
}
// publicKeyPEM encodes the public half of a JSON Web Key as PEM. Key Vault
// never returns private material for keys, and this ignores it regardless.
func publicKeyPEM(k *azkeys.JSONWebKey) (string, error) {
if k == nil {
return "", errors.New("key has no key material")
}
var pub any
switch {
case len(k.N) > 0 && len(k.E) > 0:
pub = &rsa.PublicKey{N: new(big.Int).SetBytes(k.N), E: int(new(big.Int).SetBytes(k.E).Int64())}
case len(k.X) > 0 && len(k.Y) > 0:
var curve elliptic.Curve
switch deref(k.Crv) {
case azkeys.CurveNameP256:
curve = elliptic.P256()
case azkeys.CurveNameP384:
curve = elliptic.P384()
case azkeys.CurveNameP521:
curve = elliptic.P521()
default:
return "", fmt.Errorf("cannot encode curve %s as PEM", deref(k.Crv))
}
size := (curve.Params().BitSize + 7) / 8
point := make([]byte, 1+2*size)
point[0] = 4 // uncompressed
copy(point[1:], leftPad(k.X, size))
copy(point[1+size:], leftPad(k.Y, size))
ec, err := ecdsa.ParseUncompressedPublicKey(curve, point)
if err != nil {
return "", err
}
pub = ec
default:
return "", fmt.Errorf("%s keys have no public key", deref(k.Kty))
}
der, err := x509.MarshalPKIXPublicKey(pub)
if err != nil {
return "", err
}
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}))), nil
}
func leftPad(b []byte, n int) []byte {
if len(b) >= n {
return b
}
return append(make([]byte, n-len(b)), b...)
}
// certAttrs describes an X.509 certificate.
func certAttrs(cert *x509.Certificate, thumbprint []byte) []provider.Attr {
attrs := []provider.Attr{
{Label: "subject", Value: cert.Subject.String()},
{Label: "issuer", Value: cert.Issuer.String()},
}
if len(thumbprint) > 0 {
attrs = append(attrs, provider.Attr{Label: "thumbprint", Value: hexColon(thumbprint)})
}
var sans []string
sans = append(sans, cert.DNSNames...)
sans = append(sans, cert.EmailAddresses...)
for _, ip := range cert.IPAddresses {
sans = append(sans, ip.String())
}
for _, u := range cert.URIs {
sans = append(sans, u.String())
}
if len(sans) > 0 {
attrs = append(attrs, provider.Attr{Label: "SANs", Value: strings.Join(sans, ", ")})
}
attrs = append(attrs, provider.Attr{Label: "valid from", Value: cert.NotBefore.Local().Format("2006-01-02 15:04")})
return attrs
}
func certPEM(der []byte) string {
return strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})))
}
func hexColon(b []byte) string {
parts := make([]string, len(b))
for i, x := range b {
parts[i] = fmt.Sprintf("%02X", x)
}
return strings.Join(parts, ":")
}
// cleanError shortens the SDK's multi-line HTTP errors to "403 Forbidden:
// <service message>", keeping the original in the chain.
func cleanError(err error) error {
var re *azcore.ResponseError
if !errors.As(err, &re) {
return err
}
msg := re.ErrorCode
if re.RawResponse != nil {
if body, rerr := runtime.Payload(re.RawResponse); rerr == nil {
var payload struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &payload) == nil && payload.Error.Message != "" {
msg = payload.Error.Message
}
}
}
return &serviceError{status: re.StatusCode, msg: shortenDenied(msg), err: err}
}
// shortenDenied reduces Key Vault's RBAC denial, which runs to several
// sentences plus caller and resource IDs, to its first sentence and the
// missing action.
func shortenDenied(msg string) string {
const marker = "Action: '"
i := strings.Index(msg, marker)
if i < 0 {
return msg
}
action, _, ok := strings.Cut(msg[i+len(marker):], "'")
if !ok || action == "" {
return msg
}
first := msg
if k := strings.IndexAny(first, "\r\n"); k >= 0 {
first = first[:k]
}
first, _, _ = strings.Cut(first, ". ")
return strings.TrimSuffix(first, ".") + "; missing " + action
}
type serviceError struct {
status int
msg string
err error
}
func (e *serviceError) Error() string {
s := fmt.Sprintf("%d %s", e.status, http.StatusText(e.status))
if e.msg != "" {
s += ": " + e.msg
}
return s
}
func (e *serviceError) Unwrap() error { return e.err }
+367
View File
@@ -0,0 +1,367 @@
// Package databricks is the Databricks provider. Sources are workspace
// profiles from ~/.databrickscfg, containers are secret scopes, and objects
// are secrets. Secrets in Azure Key Vault-backed scopes are read through
// Databricks, falling back to the vault itself.
package databricks
import (
"cmp"
"context"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/databricks/databricks-sdk-go/apierr"
"github.com/databricks/databricks-sdk-go/client"
dbconfig "github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/service/workspace"
"gopkg.in/ini.v1"
"git.kebcloud.no/keb/lazysecrets/internal/config"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/azure"
)
// KeyVault reads Azure Key Vault secrets directly; the Azure provider
// implements it. Containers passed to it only have Detail (the vault URL)
// set.
type KeyVault interface {
Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error)
Value(ctx context.Context, o provider.Object) (provider.SecretValue, error)
}
type profile struct {
name string
host string
authType string
}
type Provider struct {
profiles []profile
keyVault KeyVault // nil disables the fallback
newClient func(profile) (*workspace.SecretsAPI, error)
mu sync.Mutex
clients map[string]*workspace.SecretsAPI // by profile name
vaults map[string]string // AKV-backed scope ID → vault URL
}
var _ provider.Provider = (*Provider)(nil)
// New reads workspace profiles from the Databricks config file
// ($DATABRICKS_CONFIG_FILE or ~/.databrickscfg), leaving out hideProfiles.
// A missing file yields a provider with no sources.
func New(kv KeyVault, hideProfiles []string) (*Provider, error) {
path := os.Getenv("DATABRICKS_CONFIG_FILE")
if path == "" {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
path = filepath.Join(home, ".databrickscfg")
}
all, err := readProfiles(path)
if err != nil {
return nil, err
}
var profiles []profile
for _, pr := range all {
if !config.Hidden(hideProfiles, pr.name, pr.name) {
profiles = append(profiles, pr)
}
}
return &Provider{
profiles: profiles,
keyVault: kv,
newClient: profileClient,
clients: map[string]*workspace.SecretsAPI{},
vaults: map[string]string{},
}, nil
}
// profileClient is a client for the secrets API alone. The SDK's
// WorkspaceClient would link in every Databricks service, some 10 MB.
func profileClient(p profile) (*workspace.SecretsAPI, error) {
return secretsClient(&dbconfig.Config{Profile: p.name})
}
func secretsClient(cfg *dbconfig.Config) (*workspace.SecretsAPI, error) {
c, err := client.New(cfg)
if err != nil {
return nil, err
}
return workspace.NewSecrets(c), nil
}
// readProfiles returns the workspace profiles in file order, skipping
// account-console profiles, which have no secret scopes.
func readProfiles(path string) ([]profile, error) {
f, err := ini.Load(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var out []profile
for _, s := range f.Sections() {
host := s.Key("host").String()
if host == "" || s.HasKey("account_id") || strings.Contains(host, "accounts.") {
continue
}
out = append(out, profile{name: s.Name(), host: host, authType: s.Key("auth_type").String()})
}
return out, nil
}
// loginCommand is what signs the user back in for a profile, or nil if
// there is nothing to run (e.g. a personal access token).
func loginCommand(p profile) []string {
switch p.authType {
case "azure-cli":
return []string{"az", "login"}
case "pat":
return nil
default:
return []string{"databricks", "auth", "login", "--profile", p.name}
}
}
func (p *Provider) Kind() provider.Kind { return provider.KindDatabricks }
func (p *Provider) ID() string { return string(provider.KindDatabricks) }
func (p *Provider) Label() string { return string(provider.KindDatabricks) }
func (p *Provider) Sources(context.Context) ([]provider.Source, error) {
out := make([]provider.Source, 0, len(p.profiles))
for _, pr := range p.profiles {
host := strings.TrimPrefix(pr.host, "https://")
out = append(out, provider.Source{
Kind: provider.KindDatabricks, ID: pr.name, Name: pr.name,
Detail: host, URL: "https://" + host,
})
}
return out, nil
}
func (p *Provider) profile(name string) (profile, bool) {
for _, pr := range p.profiles {
if pr.name == name {
return pr, true
}
}
return profile{}, false
}
func (p *Provider) client(name string) (*workspace.SecretsAPI, profile, error) {
pr, ok := p.profile(name)
if !ok {
return nil, pr, fmt.Errorf("unknown profile %q", name)
}
p.mu.Lock()
defer p.mu.Unlock()
if c, ok := p.clients[name]; ok {
return c, pr, nil
}
c, err := p.newClient(pr)
if err != nil {
return nil, pr, classify(pr, err)
}
p.clients[name] = c
return c, pr, nil
}
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
w, pr, err := p.client(src.ID)
if err != nil {
return nil, err
}
scopes, err := w.ListScopesAll(ctx)
if err != nil {
return nil, classify(pr, err)
}
out := make([]provider.Container, 0, len(scopes))
p.mu.Lock()
defer p.mu.Unlock()
for _, s := range scopes {
c := provider.Container{Source: src, ID: src.ID + "/" + s.Name, Name: s.Name, Detail: "Databricks-backed"}
if s.BackendType == workspace.ScopeBackendTypeAzureKeyvault && s.KeyvaultMetadata != nil {
c.Detail = akvDetail + s.KeyvaultMetadata.DnsName
c.URL = azure.PortalResourceURL("", s.KeyvaultMetadata.ResourceId, "overview")
p.vaults[c.ID] = s.KeyvaultMetadata.DnsName
}
out = append(out, c)
}
slices.SortFunc(out, func(a, b provider.Container) int {
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
return out, nil
}
// vaultURL is the backing vault of an AKV-backed scope, or "". It falls
// back to the scope's Detail, so a scope remembered from an earlier session
// (a favourite) works before its workspace's scopes are listed.
func (p *Provider) vaultURL(c provider.Container) string {
p.mu.Lock()
url := p.vaults[c.ID]
p.mu.Unlock()
if url == "" {
url, _ = strings.CutPrefix(c.Detail, akvDetail)
if url == c.Detail {
url = ""
}
}
return url
}
const akvDetail = "Azure Key Vault-backed: "
func secretObject(c provider.Container, key string, updated time.Time) provider.Object {
return provider.Object{
Container: c,
Type: provider.TypeSecret,
ID: c.ID + "/" + key,
Name: key,
Updated: updated,
Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
URL: azure.ObjectListURL(c, provider.TypeSecret), // AKV-backed only
}
}
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
if t != provider.TypeSecret {
return nil, provider.ErrUnsupported
}
w, pr, err := p.client(c.Source.ID)
if err != nil {
return nil, err
}
var out []provider.Object
secrets, err := w.ListSecretsAll(ctx, workspace.ListSecretsRequest{Scope: c.Name})
if err == nil {
for _, s := range secrets {
var updated time.Time
if s.LastUpdatedTimestamp > 0 {
updated = time.UnixMilli(s.LastUpdatedTimestamp)
}
out = append(out, secretObject(c, s.Key, updated))
}
} else {
dbxErr := classify(pr, err)
out, err = p.vaultObjects(ctx, c)
if err != nil {
return nil, combine(dbxErr, err)
}
}
slices.SortFunc(out, func(a, b provider.Object) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })
return out, nil
}
// vaultObjects lists an AKV-backed scope's secrets from the vault itself.
func (p *Provider) vaultObjects(ctx context.Context, c provider.Container) ([]provider.Object, error) {
url := p.vaultURL(c)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return nil, errNoFallback
}
secrets, err := p.keyVault.Objects(ctx, provider.Container{Detail: url}, provider.TypeSecret)
if err != nil {
return nil, err
}
out := make([]provider.Object, 0, len(secrets))
for _, s := range secrets {
out = append(out, secretObject(c, s.Name, s.Updated))
}
return out, nil
}
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
return o, ctx.Err()
}
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
return nil, provider.ErrUnsupported
}
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
w, pr, err := p.client(o.Container.Source.ID)
if err != nil {
return provider.SecretValue{}, err
}
resp, err := w.GetSecret(ctx, workspace.GetSecretRequest{Scope: o.Container.Name, Key: o.Name})
if err == nil {
b, derr := base64.StdEncoding.DecodeString(resp.Value)
if derr != nil {
return provider.SecretValue{}, fmt.Errorf("decoding secret: %w", derr)
}
return provider.NewSecretValue(b), nil
}
dbxErr := classify(pr, err)
url := p.vaultURL(o.Container)
if url == "" || p.keyVault == nil || ctx.Err() != nil {
return provider.SecretValue{}, dbxErr
}
v, kvErr := p.keyVault.Value(ctx, provider.Object{
Container: provider.Container{Detail: url},
Type: provider.TypeSecret,
Name: o.Name,
})
if kvErr != nil {
return provider.SecretValue{}, combine(dbxErr, kvErr)
}
return v, nil
}
var errNoFallback = errors.New("no Key Vault fallback")
// combine reports both the Databricks and the Key Vault failure. An auth
// error from either stays reachable with errors.As.
func combine(dbxErr, kvErr error) error {
if errors.Is(kvErr, errNoFallback) {
return dbxErr
}
return fmt.Errorf("%w; Key Vault fallback: %w", dbxErr, kvErr)
}
// classify turns authentication failures into *provider.AuthError: a 401
// from the API, or failing to get a token before the request was sent.
func classify(pr profile, err error) error {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
var api *apierr.APIError
if errors.As(err, &api) {
if api.StatusCode != 401 {
return err
}
} else if !strings.Contains(err.Error(), "cannot get access token") &&
!strings.Contains(err.Error(), " auth: ") {
return err
}
return &provider.AuthError{Kind: provider.KindDatabricks, Login: loginCommand(pr), Err: err}
}
var _ provider.Locator = (*Provider)(nil)
// Locate finds the profile for a workspace host. It doesn't contact the
// workspace.
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
want := normalizeHost(host)
for _, pr := range p.profiles {
if normalizeHost(pr.host) == want {
return pr.name, true, nil
}
}
return "", false, nil
}
func normalizeHost(h string) string {
h = strings.ToLower(strings.TrimSpace(h))
h = strings.TrimPrefix(h, "https://")
return strings.TrimSuffix(h, "/")
}
@@ -0,0 +1,293 @@
package databricks
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
dbconfig "github.com/databricks/databricks-sdk-go/config"
"github.com/databricks/databricks-sdk-go/service/workspace"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
func TestReadProfiles(t *testing.T) {
path := filepath.Join(t.TempDir(), "databrickscfg")
cfg := `[DEFAULT]
host = https://adb-1.1.azuredatabricks.net
auth_type = azure-cli
[INFRA-DEV]
host = adb-2.2.azuredatabricks.net
auth_type = azure-cli
[ACCOUNT]
host = https://accounts.azuredatabricks.net
auth_type = azure-cli
[acct-by-id]
host = https://example.cloud.databricks.com
account_id = 123
[oauth]
host = https://adb-3.3.azuredatabricks.net
auth_type = databricks-cli
[no-host]
token = x
`
if err := os.WriteFile(path, []byte(cfg), 0o600); err != nil {
t.Fatal(err)
}
got, err := readProfiles(path)
if err != nil {
t.Fatal(err)
}
want := []profile{
{name: "DEFAULT", host: "https://adb-1.1.azuredatabricks.net", authType: "azure-cli"},
{name: "INFRA-DEV", host: "adb-2.2.azuredatabricks.net", authType: "azure-cli"},
{name: "oauth", host: "https://adb-3.3.azuredatabricks.net", authType: "databricks-cli"},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("profiles = %+v\nwant %+v", got, want)
}
if got, err := readProfiles(filepath.Join(t.TempDir(), "missing")); err != nil || got != nil {
t.Errorf("missing file: %v, %v", got, err)
}
}
func TestLoginCommand(t *testing.T) {
for authType, want := range map[string][]string{
"azure-cli": {"az", "login"},
"databricks-cli": {"databricks", "auth", "login", "--profile", "p"},
"": {"databricks", "auth", "login", "--profile", "p"},
"pat": nil,
} {
if got := loginCommand(profile{name: "p", authType: authType}); !reflect.DeepEqual(got, want) {
t.Errorf("%q: %v, want %v", authType, got, want)
}
}
}
// fakeKeyVault stands in for the Azure provider.
type fakeKeyVault struct {
secrets map[string]string // name → value, for vault URL kvURL
err error
calls int
}
const kvURL = "https://kv-app-prod.vault.azure.net/"
func (k *fakeKeyVault) Objects(_ context.Context, c provider.Container, _ provider.ObjectType) ([]provider.Object, error) {
k.calls++
if k.err != nil {
return nil, k.err
}
if c.Detail != kvURL {
return nil, errors.New("wrong vault " + c.Detail)
}
var out []provider.Object
for name := range k.secrets {
out = append(out, provider.Object{Name: name})
}
return out, nil
}
func (k *fakeKeyVault) Value(_ context.Context, o provider.Object) (provider.SecretValue, error) {
k.calls++
if k.err != nil {
return provider.SecretValue{}, k.err
}
if o.Container.Detail != kvURL {
return provider.SecretValue{}, errors.New("wrong vault " + o.Container.Detail)
}
return provider.NewSecretValue([]byte(k.secrets[o.Name])), nil
}
// workspaceAPI serves the secrets API. The "akv" scope behaves like an Azure
// Key Vault-backed scope whose secrets Databricks refuses to read.
func workspaceAPI(t *testing.T, status int) *httptest.Server {
t.Helper()
write := func(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if status != 0 {
write(w, status, map[string]any{"error_code": "UNAUTHENTICATED", "message": "Token is expired"})
return
}
q := r.URL.Query()
switch {
case r.URL.Path == "/api/2.0/secrets/scopes/list":
write(w, 200, map[string]any{"scopes": []any{
map[string]any{"name": "app", "backend_type": "DATABRICKS"},
map[string]any{"name": "akv", "backend_type": "AZURE_KEYVAULT",
"keyvault_metadata": map[string]any{"dns_name": kvURL, "resource_id": "/x/kv-app-prod"}},
}})
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "app":
write(w, 200, map[string]any{"secrets": []any{
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
map[string]any{"key": "client-secret"},
}})
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
write(w, 200, map[string]any{"key": q.Get("key"),
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
case strings.HasPrefix(r.URL.Path, "/api/2.0/secrets/") && q.Get("scope") == "akv":
write(w, 400, map[string]any{"error_code": "BAD_REQUEST",
"message": "Reading secrets from Azure KeyVault-backed scopes is not supported"})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func newTestProvider(t *testing.T, srv *httptest.Server, kv KeyVault) *Provider {
t.Helper()
return &Provider{
profiles: []profile{{name: "prod", host: srv.URL, authType: "azure-cli"}},
keyVault: kv,
newClient: func(profile) (*workspace.SecretsAPI, error) {
return secretsClient(&dbconfig.Config{Host: srv.URL, Token: "token"})
},
clients: map[string]*workspace.SecretsAPI{},
vaults: map[string]string{},
}
}
func scopes(t *testing.T, p *Provider) map[string]provider.Container {
t.Helper()
srcs, _ := p.Sources(context.Background())
cs, err := p.Containers(context.Background(), srcs[0])
if err != nil {
t.Fatal(err)
}
out := map[string]provider.Container{}
for _, c := range cs {
out[c.Name] = c
}
return out
}
func TestScopesSecretsAndValues(t *testing.T) {
p := newTestProvider(t, workspaceAPI(t, 0), nil)
sc := scopes(t, p)
if sc["app"].Detail != "Databricks-backed" || sc["akv"].Detail != "Azure Key Vault-backed: "+kvURL {
t.Errorf("scope details = %q / %q", sc["app"].Detail, sc["akv"].Detail)
}
objs, err := p.Objects(context.Background(), sc["app"], provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
if len(objs) != 2 || objs[0].Name != "client-secret" || objs[1].Name != "storage-key" {
t.Fatalf("objects = %+v", objs)
}
if objs[1].Updated.IsZero() || objs[1].Reference != "{{secrets/app/storage-key}}" {
t.Errorf("metadata = %+v", objs[1])
}
v, err := p.Value(context.Background(), objs[1])
if err != nil || v.Reveal() != "value-of-storage-key" {
t.Errorf("value = %q, %v", v.Reveal(), err)
}
}
func TestAKVScopeFallsBackToKeyVault(t *testing.T) {
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
p := newTestProvider(t, workspaceAPI(t, 0), kv)
akv := scopes(t, p)["akv"]
objs, err := p.Objects(context.Background(), akv, provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
if len(objs) != 1 || objs[0].Name != "db-password" || objs[0].Reference != "{{secrets/akv/db-password}}" {
t.Fatalf("objects = %+v", objs)
}
v, err := p.Value(context.Background(), objs[0])
if err != nil || v.Reveal() != "from-the-vault" {
t.Errorf("value = %q, %v", v.Reveal(), err)
}
}
func TestAKVFallbackForRememberedScope(t *testing.T) {
// A favourite from an earlier session: scopes not listed yet.
kv := &fakeKeyVault{secrets: map[string]string{"db-password": "from-the-vault"}}
p := newTestProvider(t, workspaceAPI(t, 0), kv)
akv := provider.Container{
Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod"},
ID: "prod/akv", Name: "akv", Detail: "Azure Key Vault-backed: " + kvURL,
}
v, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
if err != nil || v.Reveal() != "from-the-vault" {
t.Errorf("value = %q, %v", v.Reveal(), err)
}
}
func TestAKVFallbackFailureReportsBoth(t *testing.T) {
kvAuth := &provider.AuthError{Kind: provider.KindAzure, Login: []string{"az", "login"}, Err: errors.New("expired")}
p := newTestProvider(t, workspaceAPI(t, 0), &fakeKeyVault{err: kvAuth})
akv := scopes(t, p)["akv"]
_, err := p.Value(context.Background(), provider.Object{Container: akv, Name: "db-password"})
if err == nil || !strings.Contains(err.Error(), "not supported") || !strings.Contains(err.Error(), "Key Vault fallback") {
t.Fatalf("err = %v", err)
}
var auth *provider.AuthError
if !errors.As(err, &auth) {
t.Error("Key Vault auth error not reachable for the sign-in prompt")
}
}
func TestNoFallbackForDatabricksBackedScopes(t *testing.T) {
kv := &fakeKeyVault{}
p := newTestProvider(t, workspaceAPI(t, 0), kv)
app := scopes(t, p)["app"]
if _, err := p.Value(context.Background(), provider.Object{Container: app, Name: "missing"}); err != nil {
// app/get returns a value for any key; just make sure the vault wasn't asked
t.Fatal(err)
}
if kv.calls != 0 {
t.Errorf("Key Vault consulted for a Databricks-backed scope")
}
}
func TestUnauthorizedIsAuthError(t *testing.T) {
p := newTestProvider(t, workspaceAPI(t, 401), nil)
srcs, _ := p.Sources(context.Background())
_, err := p.Containers(context.Background(), srcs[0])
var auth *provider.AuthError
if !errors.As(err, &auth) {
t.Fatalf("err = %v, want AuthError", err)
}
if auth.Kind != provider.KindDatabricks || !reflect.DeepEqual(auth.Login, []string{"az", "login"}) {
t.Errorf("auth = %+v", auth)
}
}
func TestClassify(t *testing.T) {
pr := profile{name: "p", authType: "databricks-cli"}
var auth *provider.AuthError
if !errors.As(classify(pr, errors.New("default auth: azure-cli: cannot get access token: run az login")), &auth) {
t.Error("token failure not classified as auth")
}
if errors.As(classify(pr, errors.New("dial tcp: no such host")), &auth) {
t.Error("network error classified as auth")
}
if errors.As(classify(pr, context.Canceled), &auth) {
t.Error("cancellation classified as auth")
}
}
+407
View File
@@ -0,0 +1,407 @@
// Package fake is an in-memory provider used by tests and --demo mode.
package fake
import (
"cmp"
"context"
"errors"
"fmt"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Provider serves a fixed tree of sources, containers and objects,
// optionally sleeping before every call to simulate network latency.
type Provider struct {
kind provider.Kind
id, label string
delay time.Duration
sources []provider.Source
containers map[string][]provider.Container // by source ID
objects map[listKey][]provider.Object
errs map[listKey]error
values map[string]string // by object ID
versions map[string][]provider.Object // by object ID
}
type listKey struct {
container string
typ provider.ObjectType
}
var _ provider.Provider = (*Provider)(nil)
func newProvider(kind provider.Kind, delay time.Duration) *Provider {
return &Provider{
kind: kind,
delay: delay,
containers: map[string][]provider.Container{},
objects: map[listKey][]provider.Object{},
errs: map[listKey]error{},
values: map[string]string{},
versions: map[string][]provider.Object{},
}
}
func (p *Provider) Kind() provider.Kind { return p.kind }
func (p *Provider) ID() string {
if p.id != "" {
return p.id
}
return string(p.kind)
}
func (p *Provider) Label() string {
if p.label != "" {
return p.label
}
return string(p.kind)
}
// As renames the provider, to simulate several accounts of one kind.
func (p *Provider) As(id, label string) *Provider {
p.id, p.label = id, label
return p
}
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
if err := p.wait(ctx); err != nil {
return nil, err
}
return p.sources, nil
}
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
if err := p.wait(ctx); err != nil {
return nil, err
}
return p.containers[src.ID], nil
}
func (p *Provider) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
if err := p.wait(ctx); err != nil {
return nil, err
}
k := listKey{p.containerID(c), t}
if err := p.errs[k]; err != nil {
return nil, err
}
return p.objects[k], nil
}
// Describe returns o unchanged; the fixtures already carry their details.
func (p *Provider) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
return o, ctx.Err()
}
func (p *Provider) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
if err := p.wait(ctx); err != nil {
return nil, err
}
if p.kind == provider.KindDatabricks {
return nil, provider.ErrUnsupported
}
vs := p.versions[o.ID]
if len(vs) == 0 { // every object has at least its current version
return []provider.Object{o}, nil
}
out := make([]provider.Object, len(vs))
for i, v := range vs {
v.Container, v.Type, v.ID, v.Name, v.Reference, v.URL = o.Container, o.Type, o.ID, o.Name, o.Reference, o.URL
out[i] = v
}
return out, nil
}
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
if err := p.wait(ctx); err != nil {
return provider.SecretValue{}, err
}
if o.ID == "" { // built from a link, like the real providers read it
o.ID = p.containerID(o.Container) + "/" + strings.ToLower(string(cmp.Or(o.Type, provider.TypeSecret))) + "/" + o.Name
}
v, ok := p.values[o.ID+"@"+o.Version]
if !ok {
v, ok = p.values[o.ID]
}
if !ok {
return provider.SecretValue{}, fmt.Errorf("%s %q not found", strings.ToLower(string(o.Type)), o.Name)
}
return provider.NewSecretValue([]byte(v)), nil
}
// Locate finds the source holding the vault (by host) or the workspace.
func (p *Provider) Locate(ctx context.Context, host string) (string, bool, error) {
if err := p.wait(ctx); err != nil {
return "", false, err
}
for _, src := range p.sources {
if p.kind == provider.KindDatabricks && src.Detail == host {
return src.ID, true, nil
}
for _, c := range p.containers[src.ID] {
if c.Detail == "https://"+host {
return src.ID, true, nil
}
}
}
return "", false, nil
}
func (p *Provider) wait(ctx context.Context) error {
if p.delay <= 0 {
return ctx.Err()
}
t := time.NewTimer(p.delay)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
const portal = "https://portal.azure.com/#resource"
// containerID finds a container by ID, or by vault URL like the real Azure
// provider, which only needs the URL (vaults opened from links).
func (p *Provider) containerID(c provider.Container) string {
for _, cs := range p.containers {
for _, x := range cs {
if x.ID == c.ID {
return x.ID
}
}
}
// Not one listed here (built from a link): by vault URL, or a scope
// by name in its workspace.
for _, cs := range p.containers {
for _, x := range cs {
if c.Detail != "" && strings.TrimSuffix(x.Detail, "/") == strings.TrimSuffix(c.Detail, "/") {
return x.ID
}
if c.Detail == "" && c.Name == x.Name && c.Source.ID != "" && c.Source.ID == x.Source.ID {
return x.ID
}
}
}
return c.ID
}
func (p *Provider) addSource(id, name, detail string) provider.Source {
s := provider.Source{Kind: p.kind, ID: id, Name: name, Detail: detail}
if p.kind == provider.KindAzure {
s.URL = portal + "/subscriptions/" + id + "/overview"
} else {
s.URL = "https://" + detail
}
p.sources = append(p.sources, s)
return s
}
func (p *Provider) addContainer(src provider.Source, name, detail string, types ...provider.ObjectType) provider.Container {
c := provider.Container{Source: src, ID: src.ID + "/" + name, Name: name, Detail: detail, Types: types}
vault := name
if p.kind == provider.KindDatabricks {
vault, _ = strings.CutPrefix(detail, "Azure Key Vault-backed: ")
}
if p.kind == provider.KindAzure || vault != detail {
c.URL = portal + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/" + vault + "/overview"
}
p.containers[src.ID] = append(p.containers[src.ID], c)
return c
}
func (p *Provider) add(c provider.Container, t provider.ObjectType, o provider.Object, value string) provider.Object {
o.Container = c
o.Type = t
o.ID = c.ID + "/" + strings.ToLower(string(t)) + "/" + o.Name
if base, ok := strings.CutSuffix(c.URL, "/overview"); ok {
o.URL = base + map[provider.ObjectType]string{
provider.TypeSecret: "/secrets", provider.TypeKey: "/keys", provider.TypeCertificate: "/certificates",
}[t]
}
k := listKey{c.ID, t}
p.objects[k] = append(p.objects[k], o)
p.values[o.ID] = value
return o
}
func (p *Provider) fail(c provider.Container, err error, types ...provider.ObjectType) {
for _, t := range types {
p.errs[listKey{c.ID, t}] = err
}
}
var (
base = time.Date(2026, 9, 1, 9, 30, 0, 0, time.UTC)
yes = true
no = false
)
// ErrForbidden mimics a data-plane permission error from a backend.
var ErrForbidden = errors.New("403 Forbidden: caller lacks permission to list secrets")
// ErrForbiddenKeys mimics a vault where the caller can read secrets but not keys.
var ErrForbiddenKeys = errors.New("403 Forbidden: caller lacks permission to list keys")
const (
demoPublicKey = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAdemo\n-----END PUBLIC KEY-----"
demoCert = "-----BEGIN CERTIFICATE-----\nMIIBszCCAVmgAwIBAgIUDEMO\nDEMODEMODEMO\n-----END CERTIFICATE-----"
)
var allTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate}
// Azure returns a fake Azure provider with two subscriptions.
func Azure(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
objectURL := func(vault, kind, name string) string {
return fmt.Sprintf("https://%s.vault.azure.net/%s/%s", vault, kind, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
kv := p.addContainer(prod, "kv-app-prod", "https://kv-app-prod.vault.azure.net", allTypes...)
dbPassword := p.add(kv, provider.TypeSecret, provider.Object{
Name: "db-password", Version: "3f9c2a", Enabled: &yes, Updated: base,
Expires: base.AddDate(1, 0, 0), ContentType: "text/plain",
Tags: map[string]string{"owner": "data", "env": "prod"}, Reference: secretRef("kv-app-prod", "db-password"),
}, "correct-horse-battery-staple")
p.versions[dbPassword.ID] = []provider.Object{
{Version: "3f9c2a", Enabled: &yes, Updated: base, Expires: base.AddDate(1, 0, 0), ContentType: "text/plain"},
{Version: "91d3e0", Enabled: &yes, Updated: base.AddDate(0, -6, 0), Expires: base.AddDate(0, -1, 0)},
{Version: "0a77c4", Enabled: &no, Updated: base.AddDate(-1, 0, 0)},
}
p.values[dbPassword.ID+"@91d3e0"] = "previous-password"
p.values[dbPassword.ID+"@0a77c4"] = "original-password"
p.add(kv, provider.TypeSecret, provider.Object{
Name: "api-key", Version: "a17b04", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Reference: secretRef("kv-app-prod", "api-key"),
}, "sk_live_51HxDEMO0000000000000000")
p.add(kv, provider.TypeSecret, provider.Object{
Name: "legacy-token", Version: "0c11de", Enabled: &no, Updated: base.AddDate(-1, 0, 0),
Reference: secretRef("kv-app-prod", "legacy-token"),
}, "deprecated")
p.add(kv, provider.TypeKey, provider.Object{
Name: "signing-key", Version: "5e21b7", Enabled: &yes, Updated: base.AddDate(0, -3, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "RSA 2048"},
{Label: "ops", Value: "sign, verify"},
},
Reference: objectURL("kv-app-prod", "keys", "signing-key"),
}, demoPublicKey)
p.add(kv, provider.TypeKey, provider.Object{
Name: "data-encryption", Version: "c40f19", Enabled: &yes, Updated: base.AddDate(0, -5, 0),
Attrs: []provider.Attr{
{Label: "key type", Value: "EC P-256"},
{Label: "ops", Value: "wrapKey, unwrapKey"},
},
Reference: objectURL("kv-app-prod", "keys", "data-encryption"),
}, demoPublicKey)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "app-tls", Version: "77e0aa", Enabled: &yes, Updated: base.AddDate(0, -1, 0),
Expires: base.AddDate(0, 11, 0), ContentType: "application/x-pem-file",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=app.example.com"},
{Label: "issuer", Value: "CN=Example Issuing CA"},
{Label: "thumbprint", Value: "3B:1F:9A:0C:44:E2:7D:15"},
{Label: "SANs", Value: "app.example.com, www.app.example.com"},
{Label: "valid from", Value: "2026-08-01 09:30"},
},
Reference: objectURL("kv-app-prod", "certificates", "app-tls"),
}, demoCert)
p.add(kv, provider.TypeCertificate, provider.Object{
Name: "old-tls", Version: "19ab3c", Enabled: &yes, Updated: base.AddDate(-1, 0, 0),
Expires: base.AddDate(0, -6, 0), ContentType: "application/x-pkcs12",
Attrs: []provider.Attr{
{Label: "subject", Value: "CN=old.example.com"},
{Label: "issuer", Value: "CN=Example Issuing CA"},
{Label: "thumbprint", Value: "A0:77:12:DE:9B:03:C6:58"},
},
Reference: objectURL("kv-app-prod", "certificates", "old-tls"),
}, demoCert)
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
p.fail(shared, ErrForbiddenKeys, provider.TypeKey)
locked := p.addContainer(prod, "kv-restricted", "https://kv-restricted.vault.azure.net", allTypes...)
p.fail(locked, ErrForbidden, allTypes...)
dev := p.addSource("sub-dev", "platform-dev", "tenant: example.onmicrosoft.com")
kvDev := p.addContainer(dev, "kv-app-dev", "https://kv-app-dev.vault.azure.net", allTypes...)
p.add(kvDev, provider.TypeSecret, provider.Object{
Name: "db-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-app-dev", "db-password"),
}, "dev-password")
return p
}
// AzureOps returns a second fake Azure account. It shares platform-prod
// with Azure but sees a different vault there, and has a subscription of
// its own, like an admin account next to a personal one.
func AzureOps(delay time.Duration) *Provider {
p := newProvider(provider.KindAzure, delay)
secretRef := func(vault, name string) string {
return fmt.Sprintf("@Microsoft.KeyVault(SecretUri=https://%s.vault.azure.net/secrets/%s)", vault, name)
}
prod := p.addSource("sub-prod", "platform-prod", "tenant: example.onmicrosoft.com")
breakGlass := p.addContainer(prod, "kv-breakglass", "https://kv-breakglass.vault.azure.net", allTypes...)
p.add(breakGlass, provider.TypeSecret, provider.Object{
Name: "emergency-admin", Enabled: &yes, Updated: base.AddDate(0, -4, 0),
Tags: map[string]string{"owner": "ops"}, Reference: secretRef("kv-breakglass", "emergency-admin"),
}, "break-glass-demo-password")
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
p.add(shared, provider.TypeSecret, provider.Object{
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
}, "smtp-demo-password")
// Unlike the first account, ops may read keys here.
p.add(shared, provider.TypeKey, provider.Object{
Name: "backup-encryption", Version: "e9a102", Enabled: &yes, Updated: base.AddDate(0, -2, 0),
Attrs: []provider.Attr{{Label: "key type", Value: "RSA-HSM 3072"}, {Label: "ops", Value: "wrapKey, unwrapKey"}},
Reference: "https://kv-shared-prod.vault.azure.net/keys/backup-encryption",
}, demoPublicKey)
sandbox := p.addSource("sub-ops", "ops-sandbox", "tenant: example.onmicrosoft.com")
kv := p.addContainer(sandbox, "kv-ops-tools", "https://kv-ops-tools.vault.azure.net", allTypes...)
p.add(kv, provider.TypeSecret, provider.Object{
Name: "pagerduty-token", Enabled: &yes, Updated: base.AddDate(0, -1, 0), Reference: secretRef("kv-ops-tools", "pagerduty-token"),
}, "pd-demo-token")
return p
}
// Databricks returns a fake Databricks provider with two workspace profiles,
// including an Azure Key Vault-backed scope. Scopes hold secrets only.
func Databricks(delay time.Duration) *Provider {
p := newProvider(provider.KindDatabricks, delay)
secret := func(c provider.Container, key, value string) {
p.add(c, provider.TypeSecret, provider.Object{
Name: key, Updated: base, Reference: fmt.Sprintf("{{secrets/%s/%s}}", c.Name, key),
}, value)
}
prod := p.addSource("dbx-prod", "prod", "adb-1111111111111111.11.azuredatabricks.net")
app := p.addContainer(prod, "app", "Databricks-backed")
secret(app, "storage-key", "storage-demo-key")
secret(app, "sp-client-secret", "sp-demo-secret")
akv := p.addContainer(prod, "kv-app-prod", "Azure Key Vault-backed: kv-app-prod")
secret(akv, "db-password", "correct-horse-battery-staple")
dev := p.addSource("dbx-dev", "dev", "adb-2222222222222222.2.azuredatabricks.net")
devApp := p.addContainer(dev, "app", "Databricks-backed")
secret(devApp, "storage-key", "dev-storage-key")
return p
}
+171
View File
@@ -0,0 +1,171 @@
// Package provider defines the read-only interface every secret backend
// implements, and the types the UI works with.
//
// All backends map onto the same hierarchy:
//
// Source Azure subscription | Databricks workspace profile
// Container Key Vault | secret scope
// Object secret, key or certificate | secret
package provider
import (
"context"
"errors"
"fmt"
"time"
)
// Kind identifies a backend.
type Kind string
const (
KindAzure Kind = "Azure"
KindDatabricks Kind = "Databricks"
// KindRepo is a git repository's encrypted secret files (SOPS).
KindRepo Kind = "Repo"
)
// AuthError reports that the user must (re)authenticate with a backend, for
// example because their az CLI login has expired.
type AuthError struct {
Kind Kind
// Account names the identity when a provider has several, e.g.
// "Azure · admin.alice"; empty otherwise.
Account string
// Login is the command that signs the user in, e.g. ["az", "login"].
Login []string
Err error
}
func (e *AuthError) Error() string {
return fmt.Sprintf("%s: not signed in or session expired: %v", e.Who(), e.Err)
}
// Who is the account, or the kind if there is just one.
func (e *AuthError) Who() string {
if e.Account != "" {
return e.Account
}
return string(e.Kind)
}
func (e *AuthError) Unwrap() error { return e.Err }
// ErrUnsupported is returned by optional operations a backend cannot perform,
// such as listing versions of a Databricks secret.
var ErrUnsupported = errors.New("not supported by this provider")
// Source is a top-level account: an Azure subscription or a Databricks
// workspace profile.
type Source struct {
Kind Kind
// Provider is the ID of the provider instance the source came from.
// The app sets it; it routes requests when several providers share a
// Kind, e.g. one per Azure account.
Provider string
// ProviderLabel is that provider's Label, also set by the app.
ProviderLabel string
ID string
Name string
Detail string // e.g. tenant or workspace host
URL string // web page for the source, if any
// Here marks the source lazysecrets was started in (the current git
// repo): listed first, starred or not, and labelled.
Here bool
}
// ObjectType is the kind of object stored in a container.
type ObjectType string
const (
TypeSecret ObjectType = "Secret"
TypeKey ObjectType = "Key"
TypeCertificate ObjectType = "Certificate"
)
// Container holds objects: a Key Vault or a Databricks secret scope.
type Container struct {
Source Source
ID string
Name string
Detail string // e.g. vault URL, or "Azure Key Vault-backed: kv-name"
// Types lists the object types the container holds; empty means
// secrets only.
Types []ObjectType
URL string // web page for the container, if any
}
// ObjectTypes returns the container's object types, defaulting to secrets.
func (c Container) ObjectTypes() []ObjectType {
if len(c.Types) == 0 {
return []ObjectType{TypeSecret}
}
return c.Types
}
// Attr is a type-specific metadata field, shown in provider order.
type Attr struct {
Label string
Value string
}
// Object describes a secret, key or certificate without its value.
type Object struct {
Container Container
Type ObjectType
ID string
Name string
Version string
Enabled *bool
Updated time.Time
Expires time.Time
ContentType string
Tags map[string]string
// Attrs holds type-specific metadata, e.g. key type and size, or a
// certificate's subject, issuer and thumbprint.
Attrs []Attr
// Reference is how the object is referenced from config, e.g.
// "@Microsoft.KeyVault(SecretUri=...)" or "{{secrets/scope/key}}".
Reference string
URL string // web page for the object (or its list), if any
}
// Provider is a read-only secret backend. Every method may block on the
// network and must honour ctx cancellation.
type Provider interface {
Kind() Kind
// ID identifies the instance, stable across runs, e.g. "Azure" or
// "Azure:user@example.com/tenant".
ID() string
// Label names the instance in the UI, e.g. "Azure" or "Azure · user".
Label() string
Sources(ctx context.Context) ([]Source, error)
Containers(ctx context.Context, src Source) ([]Container, error)
// Objects lists objects of type t, one of c.ObjectTypes(). It returns
// metadata only; values are fetched with Value.
Objects(ctx context.Context, c Container, t ObjectType) ([]Object, error)
// Describe returns o with details that listing doesn't include, such as
// a key's type or a certificate's subject. It never fetches secret
// values; it may return o unchanged.
Describe(ctx context.Context, o Object) (Object, error)
// Versions may return ErrUnsupported.
Versions(ctx context.Context, o Object) ([]Object, error)
// Value returns a secret's value, or the public material of a key (PEM
// public key) or certificate (PEM certificate). Private key material is
// never returned for keys.
Value(ctx context.Context, o Object) (SecretValue, error)
}
// Current is implemented by providers that know the source lazysecrets
// was started in (the current git repo) and can find it faster than
// Sources lists everything, so it shows first.
type Current interface {
Current(ctx context.Context) (Source, bool)
}
// Locator is implemented by providers that can find where a container lives,
// to open links. address is a vault host (kv.vault.azure.net) or a
// workspace host. It returns the ID of the source holding it.
type Locator interface {
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
}
+987
View File
@@ -0,0 +1,987 @@
// Package repo browses the encrypted secret files in git repositories:
// a repo is a source, its secret files the containers (vault level), and
// their keys the secrets. SOPS files are the one type so far.
//
// Listing never decrypts: SOPS encrypts values only, so a file's keys and
// metadata are read directly. A value is decrypted when it's revealed or
// copied, one at a time, by the sops binary, with the keys sops itself
// uses (age, Azure Key Vault through az, AWS/GCP KMS, PGP).
//
// Which files: tracked by git, then, in order of preference, the include
// and exclude globs of the repo's .lazysecrets.yaml, those of the config,
// or where the repo's .sops.yaml creation_rules say SOPS files go; a repo
// with none of those is searched whole.
package repo
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
type Settings struct {
// Dir is where lazysecrets started: the repo containing it, if any,
// is listed first and marked Here, with its files under Dir first.
Dir string
// Paths are repos to show.
Paths []string
// Roots are folders of repos (like ~/git): their repos, and repos one
// level further down (org/repo).
Roots []string
// Starred are repos the user starred something in, shown wherever
// they are.
Starred []string
// Include and Exclude are globs relative to a repo's root (** matches
// any depth), for repos without their own .lazysecrets.yaml.
Include, Exclude []string
// Sops is the sops binary; "sops" (on PATH) when empty.
Sops string
}
type Provider struct {
s Settings
repos []repoInfo
err error
once sync.Once
// here is the repo containing Dir, found on its own so it can show
// before the roots are scanned.
here *repoInfo
hereOnce sync.Once
}
var (
_ provider.Provider = (*Provider)(nil)
_ provider.Locator = (*Provider)(nil)
_ provider.Current = (*Provider)(nil)
)
type repoInfo struct {
root string // absolute
name string // the folder, or its path under a root (org/repo)
id string // the remote without scheme or .git, else the root path
web string // the remote's web page, if it has one
prefix string // Dir relative to root ("" when Dir isn't in it)
here bool // the repo lazysecrets started in
}
func New(s Settings) *Provider {
if s.Sops == "" {
s.Sops = "sops"
}
return &Provider{s: s}
}
func (p *Provider) Kind() provider.Kind { return provider.KindRepo }
func (p *Provider) ID() string { return "Repos" }
func (p *Provider) Label() string { return "Repos" }
func (p *Provider) load() {
p.once.Do(func() {
seen := map[string]bool{}
var roots []string
names := map[string]string{} // path under a root (org/repo)
add := func(root string) {
if !seen[root] {
seen[root] = true
roots = append(roots, root)
}
}
// The repo lazysecrets started in, if any: marked, so the UI can
// put it on top, with its files under Dir first.
here, inRepo := p.current()
if inRepo {
seen[here.root] = true
}
for _, dir := range p.s.Paths {
root, err := gitRoot(expandHome(dir))
if err != nil {
p.err = errors.Join(p.err, fmt.Errorf("%s: not a git repository", dir))
continue
}
add(root)
}
for _, dir := range p.s.Roots {
base, _ := filepath.EvalSymlinks(expandHome(dir))
for _, root := range reposIn(expandHome(dir), 2) {
if name, err := filepath.Rel(base, root); err == nil && !strings.HasPrefix(name, "..") {
names[root] = filepath.ToSlash(name)
}
add(root)
}
}
// Repos you starred something in, wherever they are.
for _, dir := range p.s.Starred {
if root, err := gitRoot(dir); err == nil {
add(root)
}
}
// A git call each for the remote: dozens under ~/git, so several
// at a time.
infos := make([]repoInfo, len(roots))
parallel(len(roots), func(i int) {
infos[i] = info(roots[i], "")
if n := names[roots[i]]; n != "" {
infos[i].name = n
}
})
if inRepo {
infos = append([]repoInfo{here}, infos...)
}
p.repos = infos
})
}
// current is the repo lazysecrets started in.
func (p *Provider) current() (repoInfo, bool) {
p.hereOnce.Do(func() {
root, err := gitRoot(p.s.Dir)
if err != nil {
return
}
rel, _ := filepath.Rel(root, absPath(p.s.Dir))
if rel == "." {
rel = ""
}
r := info(root, filepath.ToSlash(rel))
r.here = true
p.here = &r
})
if p.here == nil {
return repoInfo{}, false
}
return *p.here, true
}
// Current is the repo lazysecrets started in, if it has secret files:
// one repo to check, so it can show before the others are.
func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
r, ok := p.current()
if !ok {
return provider.Source{}, false
}
src := p.source(r)
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 {
return provider.Source{}, false
}
return src, true
}
func (p *Provider) source(r repoInfo) provider.Source {
return provider.Source{
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
ID: r.root, Name: r.name, Detail: r.id, URL: r.web, Here: r.here,
}
}
// parallel runs f for 0..n-1, several at a time.
func parallel(n int, f func(i int)) {
var wg sync.WaitGroup
sem := make(chan struct{}, max(8, runtime.NumCPU()))
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
f(i)
}()
}
wg.Wait()
}
// reposIn finds git repositories in dir, depth levels down at most,
// without looking inside a repo or hidden folders.
func reposIn(dir string, depth int) []string {
entries, err := os.ReadDir(dir)
if err != nil || depth == 0 {
return nil
}
var out []string
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
path := filepath.Join(dir, e.Name())
if _, err := os.Stat(filepath.Join(path, ".git")); err == nil {
if abs, err := filepath.EvalSymlinks(path); err == nil {
path = abs
}
out = append(out, path)
continue
}
out = append(out, reposIn(path, depth-1)...)
}
return out
}
func info(root, prefix string) repoInfo {
id, web := remoteID(root)
if id == "" {
id = root
}
return repoInfo{root: root, name: filepath.Base(root), id: id, web: web, prefix: prefix}
}
// Sources lists the repos that have secret files: one without any isn't
// worth a row.
func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
p.load()
// Each repo is checked for secret files, several at a time: under
// ~/git that's dozens of repos.
keep := make([]bool, len(p.repos))
srcs := make([]provider.Source, len(p.repos))
for i, r := range p.repos {
srcs[i] = p.source(r)
}
parallel(len(srcs), func(i int) {
files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0
})
var out []provider.Source
for i, src := range srcs {
if keep[i] {
out = append(out, src)
}
}
if len(out) == 0 && p.err != nil {
return nil, p.err
}
return out, nil
}
func (p *Provider) repo(id string) (repoInfo, bool) {
if r, ok := p.current(); ok && r.root == id {
return r, true // without waiting for the others
}
p.load()
for _, r := range p.repos {
if r.root == id {
return r, true
}
}
return repoInfo{}, false
}
// Containers lists a repo's secret files: those under where lazysecrets
// started first, then the rest, each by path.
func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
r, ok := p.repo(src.ID)
if !ok {
return nil, fmt.Errorf("unknown repo %s", src.ID)
}
files, err := tracked(ctx, r.root)
if err != nil {
return nil, err
}
user, sopsWhere, err := p.selector(r.root, files)
if err != nil {
return nil, err
}
// SOPS files are tracked, where .sops.yaml says they go. Env files
// usually aren't tracked (they're in .gitignore), so they're looked
// for by name among untracked and ignored files too.
envs := envFiles(r.root, files)
seen := map[string]bool{}
var out []provider.Container
add := func(f string) {
if seen[f] || !user(f) {
return
}
seen[f] = true
path := filepath.Join(r.root, f)
c := provider.Container{Source: src, ID: path, Name: f, Types: []provider.ObjectType{provider.TypeSecret}}
switch meta, isSops := readMeta(path); {
case isSops && sopsWhere(f):
c.Detail = "sops · " + strings.Join(meta.methods, ", ")
case isSops:
return // a SOPS file where .sops.yaml says SOPS files don't go
case isEnv(f):
c.Detail = "env · plain text"
default:
return
}
out = append(out, c)
}
for _, f := range envs {
add(f)
}
for _, f := range files {
if !inPackages(f) {
add(f)
}
}
under := func(c provider.Container) bool {
return r.prefix == "" || strings.HasPrefix(c.Name, r.prefix+"/")
}
slices.SortStableFunc(out, func(a, b provider.Container) int {
if ua, ub := under(a), under(b); ua != ub {
if ua {
return -1
}
return 1
}
return strings.Compare(a.Name, b.Name)
})
return out, nil
}
func (p *Provider) Objects(_ context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
if t != provider.TypeSecret {
return nil, nil
}
b, err := os.ReadFile(c.ID)
if err != nil {
return nil, err
}
meta, isSops := readMeta(c.ID)
keys, err := parseKeys(c.ID, b)
if err != nil {
return nil, fmt.Errorf("%s: %w", c.Name, err)
}
if !isSops { // a plain env file
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.extract, Name: k.name,
Attrs: []provider.Attr{{Label: "format", Value: "env"}, {Label: "encrypted", Value: "no: plain text in the file"}},
})
}
return out, nil
}
out := make([]provider.Object, 0, len(keys))
for _, k := range keys {
o := provider.Object{
Container: c, Type: provider.TypeSecret,
ID: c.ID + "\x00" + k.extract, Name: k.name, Updated: meta.modified,
Attrs: []provider.Attr{{Label: "format", Value: "sops " + formatOf(c.ID)}, {Label: "encryption", Value: strings.Join(meta.methods, ", ")}},
}
if !k.encrypted {
o.Attrs = append(o.Attrs, provider.Attr{Label: "encrypted", Value: "no (left in plain text by the file's sops rules)"})
}
out = append(out, o)
}
return out, nil
}
func (p *Provider) Describe(_ context.Context, o provider.Object) (provider.Object, error) {
return o, nil
}
func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
return nil, provider.ErrUnsupported
}
// Value decrypts one key with sops.
func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
file, extract, ok := strings.Cut(o.ID, "\x00")
if !ok { // made from a link: the repo's root, the file's path, the key
file, extract = o.Container.ID, extractFor(o.Name)
if file == "" {
file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name))
}
}
if _, isSops := readMeta(file); !isSops {
return plainValue(file, o.Name)
}
cmd := exec.CommandContext(ctx, p.s.Sops, "decrypt", "--extract", extract, file)
cmd.Dir = filepath.Dir(file) // sops looks for .sops.yaml from here
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return provider.SecretValue{}, errors.New("sops isn't installed: decrypting needs it (brew install sops)")
}
return provider.SecretValue{}, errors.New(sopsError(stderr.String(), err))
}
return provider.NewSecretValue(stdout.Bytes()), nil
}
// sopsError is sops' own message, whole: which keys it tried and why
// each failed can all matter.
func sopsError(stderr string, err error) string {
msg := strings.TrimSpace(stderr)
if msg == "" {
msg = err.Error()
}
return "sops couldn't decrypt it:\n" + msg
}
// Locate finds the repo a link names, by its remote (or root path).
func (p *Provider) Locate(_ context.Context, host string) (string, bool, error) {
p.load()
for _, r := range p.repos {
if strings.EqualFold(r.id, host) {
return r.root, true, nil
}
}
return "", false, nil
}
// --- git ---
func gitRoot(dir string) (string, error) {
if dir == "" {
return "", errors.New("no directory")
}
out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func tracked(ctx context.Context, root string) ([]string, error) {
out, err := exec.CommandContext(ctx, "git", "-C", root, "ls-files", "-z").Output()
if err != nil {
return nil, fmt.Errorf("git ls-files in %s: %w", root, err)
}
var files []string
for _, f := range strings.Split(string(out), "\x00") {
if f != "" {
files = append(files, f)
}
}
return files, nil
}
// remoteID turns the origin remote into an ID ("git.kebcloud.no/keb/repo")
// and a web URL, both empty without a remote.
func remoteID(root string) (string, string) {
// From .git/config directly: a git process per repo is most of what
// listing dozens of them costs. git answers for worktrees and
// submodules (.git is a file) and remotes it rewrites (insteadOf).
if url, ok := originURL(filepath.Join(root, ".git", "config")); ok {
if url == "" {
return "", ""
}
return normalizeRemote(url)
}
out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output()
if err != nil {
return "", ""
}
return normalizeRemote(strings.TrimSpace(string(out)))
}
// originURL reads [remote "origin"] url from a git config file. It isn't
// ok when the file is missing or uses insteadOf, which only git resolves.
func originURL(config string) (string, bool) {
b, err := os.ReadFile(config)
if err != nil || bytes.Contains(b, []byte("insteadOf")) || bytes.Contains(b, []byte("insteadof")) {
return "", false
}
inOrigin := false
for line := range strings.SplitSeq(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") {
inOrigin = line == `[remote "origin"]`
continue
}
if k, v, ok := strings.Cut(line, "="); ok && inOrigin && strings.TrimSpace(k) == "url" {
return strings.TrimSpace(v), true
}
}
return "", true // no origin
}
// normalizeRemote handles https://host/path(.git), ssh://user@host[:port]/path
// and scp-style user@host:path.
func normalizeRemote(remote string) (string, string) {
u := strings.TrimSuffix(remote, ".git")
scheme := false
for _, s := range []string{"https://", "http://", "ssh://", "git://"} {
if rest, ok := strings.CutPrefix(u, s); ok {
u, scheme = rest, true
}
}
host, rest, _ := strings.Cut(u, "/")
if !scheme {
// scp-style: user@host:path
if h, r, ok := strings.Cut(u, ":"); ok {
host, rest = h, r
}
}
if at := strings.LastIndex(host, "@"); at >= 0 {
host = host[at+1:]
}
host, _, _ = strings.Cut(host, ":") // a port
if host == "" || rest == "" {
return "", ""
}
id := strings.ToLower(host + "/" + strings.Trim(rest, "/"))
if strings.Contains(host, ".") {
return id, "https://" + host + "/" + strings.Trim(rest, "/")
}
return id, ""
}
// --- which files ---
// lazysecretsFile is a repo's own .lazysecrets.yaml.
type lazysecretsFile struct {
Include []string `yaml:"include"`
Exclude []string `yaml:"exclude"`
}
// selector says which files to look at: user (the include/exclude of the
// repo's .lazysecrets.yaml or the config) for every file, and sopsWhere
// (.sops.yaml's creation_rules, when there's no include) for SOPS files.
func (p *Provider) selector(root string, files []string) (user, sopsWhere func(string) bool, err error) {
include, exclude := p.s.Include, p.s.Exclude
if b, err := os.ReadFile(filepath.Join(root, ".lazysecrets.yaml")); err == nil {
var f lazysecretsFile
if err := yaml.Unmarshal(b, &f); err != nil {
return nil, nil, fmt.Errorf("%s/.lazysecrets.yaml: %w", root, err)
}
if len(f.Include) > 0 || len(f.Exclude) > 0 {
include, exclude = f.Include, f.Exclude
}
}
inc, err := globs(include)
if err != nil {
return nil, nil, err
}
exc, err := globs(exclude)
if err != nil {
return nil, nil, err
}
var rules []sopsRule
if len(inc) == 0 {
rules = sopsRules(root, files)
}
user = func(f string) bool {
return !anyMatch(exc, f) && (len(inc) == 0 || anyMatch(inc, f))
}
sopsWhere = func(f string) bool {
return len(rules) == 0 || slices.ContainsFunc(rules, func(r sopsRule) bool { return r.matches(f) })
}
return user, sopsWhere, nil
}
// envFiles lists env files by name (.env, .env.*, *.env), tracked or
// not, ignored ones included, leaving out examples and dependencies.
// tracked is the repo's tracked files. Untracked ones are looked for in
// the folders that have tracked files, not by walking the whole tree: an
// ignored .env sits next to the code it's for, and the walk would go
// through node_modules and the like, which is most of the time a scan
// takes.
func envFiles(root string, tracked []string) []string {
var out []string
isTracked := make(map[string]bool, len(tracked))
dirs := map[string]bool{".": true}
for _, f := range tracked {
isTracked[f] = true
if isEnv(f) {
out = append(out, f)
}
if d := path.Dir(f); !dirs[d] && !inPackages(f) {
dirs[d] = true
}
}
for d := range dirs {
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(d)))
if err != nil {
continue
}
for _, e := range entries {
f := path.Join(d, e.Name())
if e.Type().IsRegular() && !isTracked[f] && isEnv(f) {
out = append(out, f)
}
}
}
return out
}
// packages are folders of dependencies, caches and build output: never
// looked in for secret files.
var packages = map[string]bool{
"node_modules": true, "bower_components": true, "jspm_packages": true,
"vendor": true, "Pods": true, "Carthage": true,
".venv": true, "venv": true, "__pycache__": true, ".tox": true, ".nox": true,
".mypy_cache": true, ".pytest_cache": true, ".ruff_cache": true,
".terraform": true, ".terragrunt-cache": true,
".gradle": true, ".m2": true, ".yarn": true, ".pnpm-store": true,
".npm": true, ".cache": true, ".next": true, ".nuxt": true, ".svelte-kit": true,
".git": true,
}
// inPackages reports whether f is inside one of the packages folders.
func inPackages(f string) bool {
for d := range strings.SplitSeq(path.Dir(f), "/") {
if packages[d] {
return true
}
}
return false
}
// isEnv reports whether a file is named like an env file with real values:
// .env, .env.local, prod.env; not .env.example and the like, nor one in
// dependencies.
func isEnv(f string) bool {
if inPackages(f) {
return false
}
base := strings.ToLower(path.Base(f))
for _, ext := range []string{".example", ".sample", ".template", ".dist", ".tmpl"} {
if strings.HasSuffix(base, ext) {
return false
}
}
return base == ".env" || strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env")
}
// plainValue reads a key from a plain env file.
func plainValue(file, name string) (provider.SecretValue, error) {
b, err := os.ReadFile(file)
if err != nil {
return provider.SecretValue{}, err
}
for _, line := range strings.Split(string(b), "\n") {
if k, v, ok := envLine(line); ok && k == name {
return provider.NewSecretValue([]byte(v)), nil
}
}
return provider.SecretValue{}, fmt.Errorf("%s isn't in %s", name, filepath.Base(file))
}
// envLine reads KEY=value (or export KEY=value), unquoting the value and
// dropping a comment after an unquoted one.
func envLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
line = strings.TrimPrefix(line, "export ")
k, v, ok := strings.Cut(line, "=")
if !ok {
return "", "", false
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if len(v) >= 2 && (v[0] == '"' || v[0] == '\'') {
if end := strings.LastIndexByte(v, v[0]); end > 0 {
inner := v[1:end]
if v[0] == '"' {
inner = strings.NewReplacer(`\n`, "\n", `\"`, `"`, `\\`, `\`).Replace(inner)
}
return k, inner, true
}
}
if i := strings.Index(v, " #"); i >= 0 {
v = strings.TrimSpace(v[:i])
}
return k, v, true
}
// sopsRule is a creation rule of a .sops.yaml, which applies to files
// under the directory it's in.
type sopsRule struct {
dir string // relative to the repo root, "" for the root
re *regexp.Regexp
}
func (r sopsRule) matches(f string) bool {
rel := f
if r.dir != "" {
var ok bool
if rel, ok = strings.CutPrefix(f, r.dir+"/"); !ok {
return false
}
}
return r.re == nil || r.re.MatchString(rel)
}
func sopsRules(root string, files []string) []sopsRule {
var rules []sopsRule
for _, f := range files {
if path.Base(f) != ".sops.yaml" {
continue
}
b, err := os.ReadFile(filepath.Join(root, f))
if err != nil {
continue
}
var cfg struct {
CreationRules []struct {
PathRegex string `yaml:"path_regex"`
} `yaml:"creation_rules"`
}
if yaml.Unmarshal(b, &cfg) != nil {
continue
}
dir := path.Dir(f)
if dir == "." {
dir = ""
}
for _, cr := range cfg.CreationRules {
r := sopsRule{dir: dir}
if cr.PathRegex != "" {
re, err := regexp.Compile(cr.PathRegex)
if err != nil {
continue
}
r.re = re
}
rules = append(rules, r)
}
}
return rules
}
// globs compiles globs: * and ? within a path segment, ** across them.
func globs(patterns []string) ([]*regexp.Regexp, error) {
var out []*regexp.Regexp
for _, g := range patterns {
var b strings.Builder
b.WriteString("^")
for i := 0; i < len(g); i++ {
switch c := g[i]; {
case strings.HasPrefix(g[i:], "**/"):
b.WriteString("(.*/)?")
i += 2
case strings.HasPrefix(g[i:], "**"):
b.WriteString(".*")
i++
case c == '*':
b.WriteString("[^/]*")
case c == '?':
b.WriteString("[^/]")
default:
b.WriteString(regexp.QuoteMeta(string(c)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return nil, fmt.Errorf("glob %q: %w", g, err)
}
out = append(out, re)
}
return out, nil
}
func anyMatch(res []*regexp.Regexp, f string) bool {
return slices.ContainsFunc(res, func(re *regexp.Regexp) bool { return re.MatchString(f) })
}
// --- sops files ---
type sopsMeta struct {
methods []string // age, azure_kv, …
modified time.Time
}
const maxFileSize = 4 << 20
// readMeta reads a file's sops metadata; ok is false if it isn't a sops
// file of a supported format.
func readMeta(file string) (sopsMeta, bool) {
fi, err := os.Stat(file)
if err != nil || fi.Size() > maxFileSize {
return sopsMeta{}, false
}
if formatOf(file) == "" {
return sopsMeta{}, false // not a format sops writes: skip reading
}
b, err := os.ReadFile(file)
if err != nil || !bytes.Contains(b, []byte("sops")) {
return sopsMeta{}, false // cheap: most files aren't sops, no parsing
}
var m sopsMeta
known := []string{"age", "azure_kv", "kms", "gcp_kms", "hc_vault", "pgp"}
switch formatOf(file) {
case "yaml", "json":
var doc struct {
Sops map[string]any `yaml:"sops"`
}
if yaml.Unmarshal(b, &doc) != nil || doc.Sops == nil || doc.Sops["mac"] == nil {
return m, false
}
for _, k := range known {
if v, ok := doc.Sops[k].([]any); ok && len(v) > 0 {
m.methods = append(m.methods, k)
}
}
if s, ok := doc.Sops["lastmodified"].(string); ok {
m.modified, _ = time.Parse(time.RFC3339, s)
}
if t, ok := doc.Sops["lastmodified"].(time.Time); ok {
m.modified = t
}
case "dotenv", "ini":
text := string(b)
if !strings.Contains(text, "sops_mac") && !strings.Contains(text, "sops_version") {
return m, false
}
for _, k := range known {
if strings.Contains(text, "sops_"+k+"__") {
m.methods = append(m.methods, k)
}
}
if i := strings.Index(text, "sops_lastmodified"); i >= 0 {
line, _, _ := strings.Cut(text[i:], "\n")
if _, v, ok := strings.Cut(line, "="); ok {
m.modified, _ = time.Parse(time.RFC3339, strings.TrimSpace(v))
}
}
default:
return m, false
}
if len(m.methods) == 0 {
m.methods = []string{"unknown keys"}
}
return m, true
}
func formatOf(file string) string {
switch strings.ToLower(filepath.Ext(file)) {
case ".yaml", ".yml":
return "yaml"
case ".json":
return "json"
case ".env":
return "dotenv"
case ".ini":
return "ini"
}
if strings.HasPrefix(filepath.Base(file), ".env") {
return "dotenv"
}
return ""
}
type key struct {
name string // db.password, list[0]
extract string // sops --extract: ["db"]["password"]
encrypted bool
}
// parseKeys lists a sops file's keys without decrypting anything.
func parseKeys(file string, b []byte) ([]key, error) {
switch formatOf(file) {
case "yaml", "json":
var doc yaml.Node
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, err
}
if len(doc.Content) == 0 {
return nil, nil
}
var out []key
walk(doc.Content[0], "", "", true, &out)
return out, nil
case "dotenv":
var out []key
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := envLine(line)
if !ok || strings.HasPrefix(k, "sops_") {
continue
}
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC[")})
}
return out, nil
case "ini":
var out []key
section := ""
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
switch {
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
case strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]"):
section = strings.TrimSpace(line[1 : len(line)-1])
case section != "sops":
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC[")})
}
}
return out, nil
}
return nil, fmt.Errorf("unsupported format")
}
// walk collects the leaves of a YAML/JSON tree, skipping sops' own block.
func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
switch n.Kind {
case yaml.MappingNode:
for i := 0; i+1 < len(n.Content); i += 2 {
k := n.Content[i].Value
if top && k == "sops" {
continue
}
child := k
if name != "" {
child = name + "." + k
}
walk(n.Content[i+1], child, extract+quote(k), false, out)
}
case yaml.SequenceNode:
for i, c := range n.Content {
walk(c, fmt.Sprintf("%s[%d]", name, i), fmt.Sprintf("%s[%d]", extract, i), false, out)
}
case yaml.AliasNode:
if n.Alias != nil {
walk(n.Alias, name, extract, false, out)
}
default:
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC[")})
}
}
// quote is a sops --extract path element.
func quote(k string) string {
return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]`
}
// extractFor rebuilds the extract path from a key name, for objects made
// from a link (db.password, list[0]). Names with dots in keys can't be
// told apart from nesting: those come from the listing instead.
func extractFor(name string) string {
var b strings.Builder
for _, part := range strings.Split(name, ".") {
base, idx, _ := strings.Cut(part, "[")
if base != "" {
b.WriteString(quote(base))
}
if idx != "" {
b.WriteString("[" + idx)
}
}
return b.String()
}
func absPath(p string) string {
if a, err := filepath.Abs(p); err == nil {
return a
}
return p
}
func expandHome(p string) string {
if rest, ok := strings.CutPrefix(p, "~/"); ok {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, rest)
}
}
return p
}
+437
View File
@@ -0,0 +1,437 @@
package repo
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// A sops-shaped YAML file: listing reads its keys and metadata only.
const sopsYAML = `db:
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
port: ENC[AES256_GCM,data:x,iv:x,tag:x,type:int]
list:
- ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
public_unencrypted: hello
sops:
age:
- recipient: age1xxxx
enc: x
lastmodified: "2026-10-09T10:05:29Z"
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
version: 3.13.3
`
const sopsEnv = "API_KEY=ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]\n" +
"sops_age__list_0__map_recipient=age1xxxx\nsops_mac=ENC[x]\nsops_lastmodified=2026-10-09T10:05:29Z\nsops_version=3.13.3\n"
// gitRepo makes a git repository with files, all committed, and returns
// its root.
func gitRepo(t *testing.T, files map[string]string) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := t.TempDir()
root, _ = filepath.EvalSymlinks(root) // macOS: /var is /private/var
for name, body := range files {
p := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
for _, args := range [][]string{
{"init", "-q"}, {"add", "."},
{"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "files"},
{"remote", "add", "origin", "git@git.example.com:team/infra.git"},
} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
}
return root
}
func names[T any](items []T, name func(T) string) []string {
var out []string
for _, it := range items {
out = append(out, name(it))
}
return out
}
func TestRepoFilesAndKeys(t *testing.T) {
root := gitRepo(t, map[string]string{
"secrets/app.yaml": sopsYAML,
"svc/billing/prod.env": sopsEnv,
"svc/billing/plain.yaml": "not: sops\n",
"README.md": "# hi\n",
})
p := New(Settings{Dir: filepath.Join(root, "svc", "billing")})
ctx := context.Background()
srcs, err := p.Sources(ctx)
if err != nil || len(srcs) != 1 {
t.Fatalf("sources %v, %v", srcs, err)
}
src := srcs[0]
if src.Name != filepath.Base(root) || src.Detail != "git.example.com/team/infra" || src.URL != "https://git.example.com/team/infra" {
t.Errorf("source %+v", src)
}
files, err := p.Containers(ctx, src)
if err != nil {
t.Fatal(err)
}
// Only sops files; those under where it started first.
if got := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(got, []string{"svc/billing/prod.env", "secrets/app.yaml"}) {
t.Errorf("files %v", got)
}
if files[1].Detail != "sops · age" {
t.Errorf("detail %q", files[1].Detail)
}
keys, err := p.Objects(ctx, files[1], provider.TypeSecret)
if err != nil {
t.Fatal(err)
}
if got := names(keys, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"db.password", "db.port", "list[0]", "public_unencrypted"}) {
t.Errorf("keys %v", got)
}
if keys[0].Updated.IsZero() {
t.Error("lastmodified not read")
}
if _, extract, _ := strings.Cut(keys[2].ID, "\x00"); extract != `["list"][0]` {
t.Errorf("extract %q", extract)
}
env, _ := p.Objects(ctx, files[0], provider.TypeSecret)
if got := names(env, func(o provider.Object) string { return o.Name }); !slices.Equal(got, []string{"API_KEY"}) {
t.Errorf("env keys %v", got)
}
if id, ok, _ := p.Locate(ctx, "git.example.com/team/infra"); !ok || id != root {
t.Errorf("locate: %q %v", id, ok)
}
}
func TestWhichFiles(t *testing.T) {
files := map[string]string{
"secrets/a.yaml": sopsYAML,
"other/b.yaml": sopsYAML,
"other/testdata/c.yaml": sopsYAML,
}
list := func(root string, s Settings) []string {
s.Dir = root
p := New(s)
srcs, _ := p.Sources(context.Background())
cs, err := p.Containers(context.Background(), srcs[0])
if err != nil {
t.Fatal(err)
}
got := names(cs, func(c provider.Container) string { return c.Name })
slices.Sort(got)
return got
}
// No rules: every tracked sops file.
root := gitRepo(t, files)
if got := list(root, Settings{}); len(got) != 3 {
t.Errorf("whole repo: %v", got)
}
// The config's globs.
if got := list(root, Settings{Include: []string{"**/*.yaml"}, Exclude: []string{"**/testdata/**"}}); !slices.Equal(got, []string{"other/b.yaml", "secrets/a.yaml"}) {
t.Errorf("config globs: %v", got)
}
// .sops.yaml says where sops files go.
withRules := map[string]string{".sops.yaml": "creation_rules:\n - path_regex: secrets/.*\\.yaml$\n age: age1x\n"}
for k, v := range files {
withRules[k] = v
}
root = gitRepo(t, withRules)
if got := list(root, Settings{}); !slices.Equal(got, []string{"secrets/a.yaml"}) {
t.Errorf(".sops.yaml rules: %v", got)
}
// The repo's own .lazysecrets.yaml wins over both.
withRules[".lazysecrets.yaml"] = "include: [\"other/**\"]\nexclude: [\"**/testdata/**\"]\n"
root = gitRepo(t, withRules)
if got := list(root, Settings{Include: []string{"secrets/**"}}); !slices.Equal(got, []string{"other/b.yaml"}) {
t.Errorf(".lazysecrets.yaml: %v", got)
}
}
func TestNormalizeRemote(t *testing.T) {
for in, want := range map[string]string{
"https://git.kebcloud.no/keb/nix-config.git": "git.kebcloud.no/keb/nix-config",
"git@github.com:Team/Repo.git": "github.com/team/repo",
"ssh://git@git.example.com:2222/team/infra": "git.example.com/team/infra",
"https://user@dev.azure.com/org/proj/_git/x": "dev.azure.com/org/proj/_git/x",
} {
if got, _ := normalizeRemote(in); got != want {
t.Errorf("%s: %q, want %q", in, got, want)
}
}
}
// With sops and age installed: real encryption and decryption, with a
// throwaway key.
func TestDecrypt(t *testing.T) {
for _, bin := range []string{"sops", "age-keygen"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skip("needs " + bin)
}
}
dir := t.TempDir()
keyFile := filepath.Join(dir, "key.txt")
if out, err := exec.Command("age-keygen", "-o", keyFile).CombinedOutput(); err != nil {
t.Fatalf("age-keygen: %v %s", err, out)
}
b, _ := os.ReadFile(keyFile)
var recipient string
for _, f := range strings.Fields(string(b)) {
if strings.HasPrefix(f, "age1") {
recipient = f
}
}
plain := filepath.Join(dir, "plain.yaml")
os.WriteFile(plain, []byte("db:\n password: hunter2\nlist:\n - first\n"), 0o600)
enc, err := exec.Command("sops", "encrypt", "--age", recipient, plain).Output()
if err != nil {
t.Fatal(err)
}
root := gitRepo(t, map[string]string{"secrets.yaml": string(enc)})
t.Setenv("SOPS_AGE_KEY_FILE", keyFile)
p := New(Settings{Dir: root})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
cs, _ := p.Containers(ctx, srcs[0])
keys, _ := p.Objects(ctx, cs[0], provider.TypeSecret)
for _, k := range keys {
v, err := p.Value(ctx, k)
if err != nil {
t.Fatalf("%s: %v", k.Name, err)
}
want := map[string]string{"db.password": "hunter2", "list[0]": "first"}[k.Name]
if v.Reveal() != want {
t.Errorf("%s = %q, want %q", k.Name, v.Reveal(), want)
}
}
// From a link, without the listing: the extract path from the name.
v, err := p.Value(ctx, provider.Object{Container: cs[0], Name: "list[0]"})
if err != nil || v.Reveal() != "first" {
t.Errorf("from a link: %q %v", v.Reveal(), err)
}
// Without the key: sops' own error.
t.Setenv("SOPS_AGE_KEY_FILE", filepath.Join(dir, "missing.txt"))
if _, err := p.Value(ctx, keys[0]); err == nil || !strings.Contains(err.Error(), "no identity matched any of the recipients") {
t.Errorf("no key: %v", err)
}
}
func TestRepoWithoutSecretsIsntListed(t *testing.T) {
root := gitRepo(t, map[string]string{"README.md": "# no secrets\n", "config.yaml": "plain: yaml\n"})
srcs, err := New(Settings{Dir: root}).Sources(context.Background())
if err != nil || len(srcs) != 0 {
t.Errorf("sources %v, %v", srcs, err)
}
}
func TestPlainEnvFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
".gitignore": ".env\n.env.local\n",
".env.example": "TOKEN=changeme\n",
"secrets/app.yaml": sopsYAML,
})
// Ignored, as they usually are: written after the commit.
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nexport TOKEN=\"s3cr3t value\"\nURL=https://x.example # the API\nQUOTED='a#b'\n"), 0o600)
os.MkdirAll(filepath.Join(root, "node_modules", "pkg"), 0o755)
os.WriteFile(filepath.Join(root, "node_modules", "pkg", ".env"), []byte("X=1\n"), 0o600)
p := New(Settings{Dir: root})
ctx := context.Background()
srcs, _ := p.Sources(ctx)
files, err := p.Containers(ctx, srcs[0])
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, c := range files {
got[c.Name] = c.Detail
}
if len(got) != 2 || got[".env"] != "env · plain text" || got["secrets/app.yaml"] != "sops · age" {
t.Fatalf("files %v (want .env and the sops file; not the example or node_modules)", got)
}
var env provider.Container
for _, c := range files {
if c.Name == ".env" {
env = c
}
}
keys, _ := p.Objects(ctx, env, provider.TypeSecret)
want := map[string]string{"TOKEN": "s3cr3t value", "URL": "https://x.example", "QUOTED": "a#b"}
if len(keys) != len(want) {
t.Fatalf("keys %v", names(keys, func(o provider.Object) string { return o.Name }))
}
for _, k := range keys {
v, err := p.Value(ctx, k) // no sops involved
if err != nil || v.Reveal() != want[k.Name] {
t.Errorf("%s = %q, %v; want %q", k.Name, v.Reveal(), err, want[k.Name])
}
}
// .sops.yaml rules limit SOPS files, not plain env files.
os.WriteFile(filepath.Join(root, ".sops.yaml"), []byte("creation_rules:\n - path_regex: nowhere/.*\n"), 0o644)
exec.Command("git", "-C", root, "add", ".sops.yaml").Run()
files, _ = New(Settings{Dir: root}).Containers(ctx, srcs[0])
if names := names(files, func(c provider.Container) string { return c.Name }); !slices.Equal(names, []string{".env"}) {
t.Errorf("with rules elsewhere: %v", names)
}
}
func TestRoots(t *testing.T) {
withSecrets := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
without := gitRepo(t, map[string]string{"README.md": "# none\n"})
nested := gitRepo(t, map[string]string{".env.example": "X=y\n", "prod.env": sopsEnv})
// A folder of repos: two at the top, one under an org folder.
root, _ := filepath.EvalSymlinks(t.TempDir())
os.Rename(withSecrets, filepath.Join(root, "infra"))
os.Rename(without, filepath.Join(root, "docs"))
os.MkdirAll(filepath.Join(root, "acme"), 0o755)
os.Rename(nested, filepath.Join(root, "acme", "billing"))
outside := t.TempDir() // not in a repo
srcs, err := New(Settings{Dir: outside, Roots: []string{root}}).Sources(context.Background())
if err != nil {
t.Fatal(err)
}
got := names(srcs, func(s provider.Source) string { return s.Name })
slices.Sort(got)
if !slices.Equal(got, []string{"acme/billing", "infra"}) {
t.Errorf("repos under the root: %v (want the two with secret files)", got)
}
// Inside a repo: the same repos, that one first and marked Here.
srcs, _ = New(Settings{Dir: filepath.Join(root, "acme", "billing"), Roots: []string{root}}).Sources(context.Background())
got = names(srcs, func(s provider.Source) string { return s.Name })
if !slices.Equal(got, []string{"billing", "infra"}) || !srcs[0].Here || srcs[1].Here {
t.Errorf("inside a repo: %v, want billing (here) then infra", got)
}
}
func TestStarredReposAlongside(t *testing.T) {
here := gitRepo(t, map[string]string{"secrets/a.yaml": sopsYAML})
starred := gitRepo(t, map[string]string{"prod.env": sopsEnv})
other := gitRepo(t, map[string]string{"secrets/b.yaml": sopsYAML})
_ = other
srcs, err := New(Settings{Dir: here, Starred: []string{starred, here}}).Sources(context.Background())
if err != nil {
t.Fatal(err)
}
got := names(srcs, func(s provider.Source) string { return s.ID })
if !slices.Equal(got, []string{here, starred}) {
t.Errorf("sources %v, want this repo then the starred one (not under a root)", got)
}
}
// sops' output when no key opens the file, as sops 3.13 prints it.
const noKeyStderr = `Failed to get the data key required to decrypt the SOPS file.
Group 0: FAILED
age1el8zh94re2488t5rrpqxk5xcxhs2cg6xfcccntz6l4ggv0rsf9yq44c0cs: FAILED
- | failed to create reader for decrypting sops data key with
| age: no identity matched any of the recipients. Did not find
| keys in locations 'SOPS_AGE_SSH_PRIVATE_KEY_FILE'.
https://kv-app.vault.azure.net/keys/sops/abc: FAILED
- | failed to decrypt sops data key with Azure Key Vault key
| 'https://kv-app.vault.azure.net/keys/sops/abc': DefaultAzureCredential: failed to acquire a token.
Recovery failed because no master key was able to decrypt the file. In
order for SOPS to recover the file, at least one key has to be successful,
but none were.
`
func TestSopsError(t *testing.T) {
got := sopsError(noKeyStderr, errors.New("exit status 128"))
if got != "sops couldn't decrypt it:\n"+strings.TrimSpace(noKeyStderr) {
t.Errorf("not sops' whole message:\n%s", got)
}
if got := sopsError("", errors.New("exit status 1")); got != "sops couldn't decrypt it:\nexit status 1" {
t.Errorf("without output: %q", got)
}
}
func TestOriginURL(t *testing.T) {
dir := t.TempDir()
write := func(s string) string {
p := filepath.Join(dir, "config")
os.WriteFile(p, []byte(s), 0o644)
return p
}
for _, c := range []struct {
config, url string
ok bool
}{
{"[core]\n\tbare = false\n[remote \"origin\"]\n\turl = git@git.example.com:team/infra.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n", "git@git.example.com:team/infra.git", true},
{"[remote \"upstream\"]\n\turl = https://a/b\n[remote \"origin\"]\n\turl=https://git.example.com/x.git\n", "https://git.example.com/x.git", true},
{"[core]\n\tbare = false\n", "", true}, // no origin
{"[url \"git@host:\"]\n\tinsteadOf = https://host/\n[remote \"origin\"]\n\turl = https://host/x\n", "", false},
} {
url, ok := originURL(write(c.config))
if url != c.url || ok != c.ok {
t.Errorf("%q: %q %v, want %q %v", c.config, url, ok, c.url, c.ok)
}
}
if _, ok := originURL(filepath.Join(dir, "missing")); ok {
t.Error("a missing config should leave it to git")
}
}
func TestEnvFilesSkipPackages(t *testing.T) {
root := gitRepo(t, map[string]string{
"app/main.go": "package main\n",
"vendor/lib/x.go": "package lib\n",
"web/node_modules/pkg/index.js": "x\n",
"secrets/sops.yaml": sopsYAML,
"web/node_modules/pkg/sops.yaml": sopsYAML,
})
// Untracked, as .env files usually are: next to tracked code, in a
// package folder, and in a folder with nothing tracked.
for f, body := range map[string]string{
"app/.env": "A=1\n",
"web/node_modules/pkg/.env": "B=2\n",
"vendor/lib/.env": "C=3\n",
"scratch/.env": "D=4\n",
} {
os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755)
os.WriteFile(filepath.Join(root, f), []byte(body), 0o644)
}
p := New(Settings{Dir: root})
srcs, _ := p.Sources(context.Background())
files, err := p.Containers(context.Background(), srcs[0])
if err != nil {
t.Fatal(err)
}
got := names(files, func(c provider.Container) string { return c.Name })
slices.Sort(got)
if want := []string{"app/.env", "secrets/sops.yaml"}; !slices.Equal(got, want) {
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
}
}
+34
View File
@@ -0,0 +1,34 @@
package provider
import (
"fmt"
"log/slog"
)
const masked = "••••••"
// SecretValue holds a secret's plaintext. Every formatting, logging and
// encoding path renders it masked; the plaintext is only available through
// Reveal, so it cannot leak through a stray Printf, slog call or error.
type SecretValue struct {
b []byte
}
func NewSecretValue(b []byte) SecretValue {
return SecretValue{b: append([]byte(nil), b...)}
}
// Reveal returns the plaintext. Call it only to display or copy the value.
func (v SecretValue) Reveal() string { return string(v.b) }
func (v SecretValue) Len() int { return len(v.b) }
func (v SecretValue) String() string { return masked }
func (v SecretValue) GoString() string { return masked }
// Format covers every fmt verb, including %#v and %x.
func (v SecretValue) Format(f fmt.State, _ rune) { _, _ = f.Write([]byte(masked)) }
func (v SecretValue) LogValue() slog.Value { return slog.StringValue(masked) }
func (v SecretValue) MarshalText() ([]byte, error) { return []byte(masked), nil }
+51
View File
@@ -0,0 +1,51 @@
package provider
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"strings"
"testing"
)
func TestSecretValueNeverFormatsPlaintext(t *testing.T) {
const plain = "hunter2"
v := NewSecretValue([]byte(plain))
wrapped := struct{ V SecretValue }{v}
var outputs []string
for _, verb := range []string{"%v", "%+v", "%#v", "%s", "%q", "%x", "%X", "%d"} {
outputs = append(outputs, fmt.Sprintf(verb, v), fmt.Sprintf(verb, wrapped))
}
outputs = append(outputs, fmt.Sprint(v), fmt.Errorf("failed: %v", v).Error())
js, err := json.Marshal(wrapped)
if err != nil {
t.Fatal(err)
}
outputs = append(outputs, string(js))
var buf bytes.Buffer
slog.New(slog.NewJSONHandler(&buf, nil)).Info("x", "value", v)
slog.New(slog.NewTextHandler(&buf, nil)).Info("x", "value", v)
outputs = append(outputs, buf.String())
for _, out := range outputs {
if strings.Contains(out, plain) || strings.Contains(out, fmt.Sprintf("%x", plain)) {
t.Errorf("plaintext leaked: %q", out)
}
}
if v.Reveal() != plain {
t.Errorf("Reveal() = %q, want %q", v.Reveal(), plain)
}
}
func TestNewSecretValueCopiesInput(t *testing.T) {
b := []byte("abc")
v := NewSecretValue(b)
b[0] = 'x'
if v.Reveal() != "abc" {
t.Errorf("value aliased caller's slice: %q", v.Reveal())
}
}
+375
View File
@@ -0,0 +1,375 @@
// Package ref parses and builds references to vaults, scopes and secrets:
// lazysecrets:// links, and the formats people already share (Key Vault
// identifier URLs, App Service @Microsoft.KeyVault references, portal links
// and Databricks {{secrets/scope/key}}).
//
// A link names a vault, scope or object; what to do with it is up to
// whoever opens it:
//
// lazysecrets://azure/subscriptions/<subscription-id>
// lazysecrets://azure/<vault-host>[/<secrets|keys|certificates>[/<name>[/<version>]]]
// lazysecrets://databricks/<workspace-host>[/<scope>[/<key>]]
// lazysecrets://repo/<repo>[/<file>[/<key>]]
//
// A repo is its git remote without scheme or .git
// (git.kebcloud.no/keb/infra), the file its path in the repo and the key
// its path in the file (db.password); slashes inside them are escaped.
//
// Vault hosts always contain dots, so "subscriptions" can't be one.
//
// Every path segment is percent-encoded, so names may hold any character.
// Query parameters are ignored (early links carried ?action=).
package ref
import (
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
const Scheme = "lazysecrets"
// Ref points at a container, or an object in one.
type Ref struct {
Kind provider.Kind
// Host is the vault host (Azure), workspace host (Databricks) or repo
// (Repo). It is empty for a bare {{secrets/scope/key}}, whose workspace
// is unknown.
Host string
// Scope is the Databricks scope or the repo's file; empty for Azure.
Scope string
// Subscription is set for a link to an Azure subscription.
Subscription string
// Type is the object type, or for an Azure vault link the tab to show
// (empty: the default tab).
Type provider.ObjectType
Name string // object name; empty for a container link
Version string
}
// IsSource reports whether r points at a subscription or workspace.
func (r Ref) IsSource() bool {
if r.Kind != provider.KindAzure {
return r.Scope == ""
}
return r.Subscription != ""
}
// IsContainer reports whether r points at a vault or scope.
func (r Ref) IsContainer() bool { return r.Name == "" && !r.IsSource() }
// ContainerName is the vault name (from its host) or the scope.
func (r Ref) ContainerName() string {
if r.Kind != provider.KindAzure {
return r.Scope
}
name, _, _ := strings.Cut(r.Host, ".")
return name
}
// VaultURL is the Azure vault's URL, as providers store it in
// Container.Detail.
func (r Ref) VaultURL() string { return "https://" + r.Host + "/" }
var collections = map[provider.ObjectType]string{
provider.TypeSecret: "secrets",
provider.TypeKey: "keys",
provider.TypeCertificate: "certificates",
}
func typeOf(collection string) (provider.ObjectType, bool) {
for t, c := range collections {
if strings.EqualFold(c, collection) {
return t, true
}
}
return "", false
}
// IdentifierURL is the plain Key Vault URL of an Azure reference, e.g.
// https://kv.vault.azure.net/secrets/name[/version], usable without
// lazysecrets (az keyvault secret show --id). Empty for Databricks.
func (r Ref) IdentifierURL() string {
if r.Kind != provider.KindAzure {
return ""
}
u := "https://" + r.Host
if r.Type == "" {
return u + "/"
}
for _, seg := range []string{collections[r.Type], r.Name, r.Version} {
if seg != "" {
u += "/" + url.PathEscape(seg)
}
}
return u
}
// String is the lazysecrets:// link.
func (r Ref) String() string {
if r.Kind == provider.KindAzure && r.Subscription != "" {
return Scheme + "://azure/subscriptions/" + url.PathEscape(r.Subscription)
}
segs := []string{strings.ToLower(string(r.Kind)), r.Host}
switch r.Kind {
case provider.KindDatabricks, provider.KindRepo:
segs = append(segs, r.Scope, r.Name)
default:
if r.Type != "" {
segs = append(segs, collections[r.Type])
}
segs = append(segs, r.Name, r.Version)
}
for len(segs) > 2 && segs[len(segs)-1] == "" {
segs = segs[:len(segs)-1]
}
for i := range segs {
segs[i] = url.PathEscape(segs[i])
if r.Kind == provider.KindRepo && i > 0 {
// Repos and files have slashes: keep them in their part.
segs[i] = strings.ReplaceAll(segs[i], "/", "%2F")
}
}
return Scheme + "://" + strings.Join(segs, "/")
}
// ForSource is a link to a subscription or workspace.
func ForSource(src provider.Source) (Ref, error) {
switch src.Kind {
case provider.KindAzure:
return Ref{Kind: provider.KindAzure, Subscription: src.ID}, nil
case provider.KindDatabricks:
if src.Detail == "" {
return Ref{}, errors.New("workspace host unknown")
}
return Ref{Kind: provider.KindDatabricks, Host: strings.ToLower(src.Detail)}, nil
case provider.KindRepo:
return Ref{Kind: provider.KindRepo, Host: strings.ToLower(src.Detail)}, nil
}
return Ref{}, fmt.Errorf("can't link to %s", src.Kind)
}
// ForContainer is a link to a vault or scope.
func ForContainer(c provider.Container, tab provider.ObjectType) (Ref, error) {
switch c.Source.Kind {
case provider.KindAzure:
host, err := vaultHost(c.Detail)
if err != nil {
return Ref{}, err
}
if tab == provider.TypeSecret {
tab = "" // the default tab
}
return Ref{Kind: provider.KindAzure, Host: host, Type: tab}, nil
case provider.KindDatabricks:
if c.Source.Detail == "" {
return Ref{}, errors.New("workspace host unknown")
}
return Ref{Kind: provider.KindDatabricks, Host: strings.ToLower(c.Source.Detail), Scope: c.Name}, nil
case provider.KindRepo:
return Ref{Kind: provider.KindRepo, Host: strings.ToLower(c.Source.Detail), Scope: c.Name}, nil
}
return Ref{}, fmt.Errorf("can't link to %s", c.Source.Kind)
}
// ForObject is a link to an object, pinned to its version if given.
func ForObject(o provider.Object, version string) (Ref, error) {
r, err := ForContainer(o.Container, o.Type)
if err != nil {
return Ref{}, err
}
r.Name, r.Type = o.Name, o.Type
if r.Kind == provider.KindAzure {
r.Version = version // Databricks secrets have no versions
}
return r, nil
}
func vaultHost(vaultURL string) (string, error) {
u, err := url.Parse(vaultURL)
if err != nil || u.Host == "" {
return "", fmt.Errorf("not a vault URL: %q", vaultURL)
}
return strings.ToLower(u.Host), nil
}
var (
errUnknown = errors.New("not a lazysecrets link or a Key Vault/Databricks reference")
// @Microsoft.KeyVault(SecretUri=…) or (VaultName=…;SecretName=…[;SecretVersion=…])
appServiceRef = regexp.MustCompile(`(?i)^@Microsoft\.KeyVault\((.*)\)$`)
// {{secrets/scope/key}}
databricksRef = regexp.MustCompile(`^\{\{\s*secrets/([^/{}\s]+)/([^/{}\s]+)\s*\}\}$`)
// …/providers/Microsoft.KeyVault/vaults/<name> in a portal link
portalVault = regexp.MustCompile(`(?i)/providers/Microsoft\.KeyVault/vaults/([A-Za-z0-9-]+)`)
// …/asset/Microsoft_Azure_KeyVault/<Secret|Key|Certificate>/<identifier URL>
portalAsset = regexp.MustCompile(`(?i)/asset/Microsoft_Azure_KeyVault/[A-Za-z]+/(https://\S+)$`)
)
// Parse reads any supported reference.
func Parse(s string) (Ref, error) {
s = strings.TrimSpace(s)
switch {
case strings.HasPrefix(strings.ToLower(s), Scheme+"://"):
return parseLink(s)
case appServiceRef.MatchString(s):
return parseAppService(appServiceRef.FindStringSubmatch(s)[1])
case databricksRef.MatchString(s):
m := databricksRef.FindStringSubmatch(s)
return Ref{Kind: provider.KindDatabricks, Scope: m[1], Name: m[2], Type: provider.TypeSecret}, nil
case strings.Contains(s, "portal.azure.com"):
return parsePortal(s)
case strings.HasPrefix(strings.ToLower(s), "https://"):
return parseIdentifier(s)
}
return Ref{}, errUnknown
}
func parseLink(s string) (Ref, error) {
u, err := url.Parse(s)
if err != nil {
return Ref{}, fmt.Errorf("bad link: %w", err)
}
segs, err := pathSegments(u.EscapedPath())
if err != nil {
return Ref{}, err
}
var r Ref
if len(segs) == 0 || segs[0] == "" {
return Ref{}, errors.New("link has no vault or workspace")
}
r.Host = strings.ToLower(segs[0])
rest := segs[1:]
switch strings.ToLower(u.Host) {
case "azure":
r.Kind = provider.KindAzure
if r.Host == "subscriptions" {
if len(rest) != 1 || rest[0] == "" {
return Ref{}, errors.New("subscription link needs exactly one subscription ID")
}
return Ref{Kind: provider.KindAzure, Subscription: rest[0]}, nil
}
if len(rest) > 3 {
return Ref{}, errors.New("azure link has too many parts")
}
if len(rest) > 0 {
t, ok := typeOf(rest[0])
if !ok {
return Ref{}, fmt.Errorf("unknown object type %q (want secrets, keys or certificates)", rest[0])
}
r.Type = t
}
if len(rest) > 1 {
r.Name = rest[1]
}
if len(rest) > 2 {
r.Version = rest[2]
}
case "databricks", "repo":
r.Kind = provider.KindDatabricks
if strings.EqualFold(u.Host, "repo") {
r.Kind = provider.KindRepo
}
if len(rest) > 2 {
return Ref{}, fmt.Errorf("%s link has too many parts", strings.ToLower(u.Host))
}
if len(rest) > 0 {
r.Scope = rest[0]
}
if len(rest) > 1 {
r.Name, r.Type = rest[1], provider.TypeSecret
}
default:
return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host)
}
return r, nil
}
// pathSegments splits an escaped path on / and unescapes each segment, so
// an escaped slash (%2F) stays inside its segment.
func pathSegments(escaped string) ([]string, error) {
escaped = strings.Trim(escaped, "/")
if escaped == "" {
return nil, nil
}
parts := strings.Split(escaped, "/")
for i, p := range parts {
s, err := url.PathUnescape(p)
if err != nil {
return nil, fmt.Errorf("bad link: %w", err)
}
parts[i] = s
}
return parts, nil
}
// parseIdentifier reads a Key Vault URL: a vault, or an object identifier
// like https://kv.vault.azure.net/secrets/name[/version].
func parseIdentifier(s string) (Ref, error) {
u, err := url.Parse(s)
if err != nil || u.Host == "" {
return Ref{}, errUnknown
}
host := strings.ToLower(u.Host)
if !strings.Contains(host, ".vault.") {
return Ref{}, errUnknown
}
segs, err := pathSegments(u.EscapedPath())
if err != nil {
return Ref{}, err
}
r := Ref{Kind: provider.KindAzure, Host: host}
if len(segs) > 0 {
t, ok := typeOf(segs[0])
if !ok {
return Ref{}, fmt.Errorf("unknown Key Vault path %q", u.Path)
}
r.Type = t
}
if len(segs) > 1 {
r.Name = segs[1]
}
if len(segs) > 2 {
r.Version = segs[2]
}
return r, nil
}
func parseAppService(args string) (Ref, error) {
fields := map[string]string{}
for _, kv := range strings.Split(args, ";") {
k, v, ok := strings.Cut(kv, "=")
if ok {
fields[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
}
}
if uri := fields["secreturi"]; uri != "" {
return parseIdentifier(uri)
}
vault, name := fields["vaultname"], fields["secretname"]
if vault == "" || name == "" {
return Ref{}, errors.New("@Microsoft.KeyVault reference needs SecretUri, or VaultName and SecretName")
}
return Ref{
Kind: provider.KindAzure, Host: strings.ToLower(vault) + ".vault.azure.net",
Type: provider.TypeSecret, Name: name, Version: fields["secretversion"],
}, nil
}
// parsePortal reads Azure portal links to a vault or a Key Vault object.
func parsePortal(s string) (Ref, error) {
if m := portalAsset.FindStringSubmatch(s); m != nil {
return parseIdentifier(m[1])
}
if m := portalVault.FindStringSubmatch(s); m != nil {
// Portal vault links don't carry the cloud; assume public Azure.
return Ref{Kind: provider.KindAzure, Host: strings.ToLower(m[1]) + ".vault.azure.net"}, nil
}
return Ref{}, errUnknown
}
+192
View File
@@ -0,0 +1,192 @@
package ref
import (
"reflect"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
const (
kvHost = "kv-app-prod.vault.azure.net"
dbHost = "adb-1111111111111111.11.azuredatabricks.net"
)
func TestParse(t *testing.T) {
secret := Ref{Kind: provider.KindAzure, Host: kvHost, Type: provider.TypeSecret, Name: "db-password"}
versioned := secret
versioned.Version = "91d3e0"
vault := Ref{Kind: provider.KindAzure, Host: kvHost}
keysTab := vault
keysTab.Type = provider.TypeKey
cert := Ref{Kind: provider.KindAzure, Host: kvHost, Type: provider.TypeCertificate, Name: "app-tls"}
scope := Ref{Kind: provider.KindDatabricks, Host: dbHost, Scope: "app"}
dbxSecret := Ref{Kind: provider.KindDatabricks, Host: dbHost, Scope: "app", Name: "storage-key", Type: provider.TypeSecret}
for in, want := range map[string]Ref{
// lazysecrets links
"lazysecrets://azure/" + kvHost: vault,
"lazysecrets://azure/" + kvHost + "/": vault,
"lazysecrets://azure/" + kvHost + "/keys": keysTab,
"lazysecrets://azure/" + kvHost + "/secrets/db-password": secret,
"lazysecrets://azure/" + kvHost + "/secrets/db-password/91d3e0": versioned,
"lazysecrets://azure/" + kvHost + "/secrets/db-password?action=copy": secret, // early links: ignored
"LazySecrets://Azure/" + strings.ToUpper(kvHost) + "/secrets/db-password": secret,
"lazysecrets://databricks/" + dbHost + "/app": scope,
"lazysecrets://databricks/" + dbHost + "/app/storage-key": dbxSecret,
// formats people already share
"https://" + kvHost + "/secrets/db-password": secret,
"https://" + kvHost + "/secrets/db-password/91d3e0": versioned,
"https://" + kvHost + "/": vault,
"https://" + kvHost + "/certificates/app-tls": cert,
"@Microsoft.KeyVault(SecretUri=https://" + kvHost + "/secrets/db-password/)": secret,
"@Microsoft.KeyVault(VaultName=kv-app-prod;SecretName=db-password)": secret,
"@Microsoft.KeyVault(VaultName=kv-app-prod;SecretName=db-password;SecretVersion=91d3e0)": versioned,
" {{secrets/app/storage-key}} ": {Kind: provider.KindDatabricks, Scope: "app", Name: "storage-key", Type: provider.TypeSecret},
"https://portal.azure.com/#@t/resource/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv-app-prod/overview": vault,
"https://portal.azure.com/#@t/asset/Microsoft_Azure_KeyVault/Certificate/https://" + kvHost + "/certificates/app-tls": cert,
} {
got, err := Parse(in)
if err != nil {
t.Errorf("Parse(%q): %v", in, err)
continue
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Parse(%q)\n got %+v\nwant %+v", in, got, want)
}
}
}
func TestParseRejects(t *testing.T) {
for _, in := range []string{
"",
"hunter2",
"https://example.com/secrets/x",
"lazysecrets://aws/secret",
"lazysecrets://azure/",
"lazysecrets://azure/" + kvHost + "/passwords/x",
"lazysecrets://azure/" + kvHost + "/secrets/a/b/c",
"lazysecrets://azure/subscriptions/",
"lazysecrets://azure/subscriptions/a/b",
"@Microsoft.KeyVault(VaultName=kv)",
} {
if r, err := Parse(in); err == nil {
t.Errorf("Parse(%q) = %+v, want an error", in, r)
}
}
}
func TestLinksRoundTrip(t *testing.T) {
kv := provider.Container{
Source: provider.Source{Kind: provider.KindAzure, ID: "sub"},
Name: "kv-app-prod", Detail: "https://" + kvHost + "/",
}
scope := provider.Container{
Source: provider.Source{Kind: provider.KindDatabricks, ID: "prod", Detail: dbHost},
Name: "team@data",
}
key := provider.Object{Container: kv, Type: provider.TypeKey, Name: "signing-key"}
dbx := provider.Object{Container: scope, Type: provider.TypeSecret, Name: "storage.key"}
links := map[string]func() (Ref, error){
"lazysecrets://azure/" + kvHost: func() (Ref, error) { return ForContainer(kv, provider.TypeSecret) },
"lazysecrets://azure/" + kvHost + "/keys": func() (Ref, error) { return ForContainer(kv, provider.TypeKey) },
"lazysecrets://azure/" + kvHost + "/keys/signing-key/v9": func() (Ref, error) { return ForObject(key, "v9") },
"lazysecrets://databricks/" + dbHost + "/team@data": func() (Ref, error) { return ForContainer(scope, provider.TypeSecret) },
"lazysecrets://databricks/" + dbHost + "/team@data/storage.key": func() (Ref, error) { return ForObject(dbx, "") },
}
for want, build := range links {
r, err := build()
if err != nil {
t.Fatal(err)
}
if got := r.String(); got != want {
t.Errorf("link = %q, want %q", got, want)
}
back, err := Parse(r.String())
if err != nil {
t.Fatalf("Parse(%q): %v", r.String(), err)
}
if !reflect.DeepEqual(back, r) {
t.Errorf("round trip of %q\n got %+v\nwant %+v", want, back, r)
}
}
}
func TestSlashInNamesIsEscaped(t *testing.T) {
r := Ref{Kind: provider.KindDatabricks, Host: dbHost, Scope: "a/b", Name: "c", Type: provider.TypeSecret}
back, err := Parse(r.String())
if err != nil || back.Scope != "a/b" || back.Name != "c" {
t.Errorf("%q → %+v, %v", r.String(), back, err)
}
}
func TestIdentifierURL(t *testing.T) {
for in, want := range map[string]string{
"lazysecrets://azure/" + kvHost + "/secrets/db-password/91d3e0": "https://" + kvHost + "/secrets/db-password/91d3e0",
"lazysecrets://azure/" + kvHost + "/keys/signing-key": "https://" + kvHost + "/keys/signing-key",
"lazysecrets://azure/" + kvHost: "https://" + kvHost + "/",
"lazysecrets://databricks/" + dbHost + "/app/k": "",
} {
r, err := Parse(in)
if err != nil {
t.Fatal(err)
}
if got := r.IdentifierURL(); got != want {
t.Errorf("%s: %q, want %q", in, got, want)
}
}
}
func TestSourceLinks(t *testing.T) {
az := provider.Source{Kind: provider.KindAzure, ID: "4d3073a0-1111"}
dbx := provider.Source{Kind: provider.KindDatabricks, ID: "prod", Detail: dbHost}
for src, want := range map[provider.Source]string{
az: "lazysecrets://azure/subscriptions/4d3073a0-1111",
dbx: "lazysecrets://databricks/" + dbHost,
} {
r, err := ForSource(src)
if err != nil {
t.Fatal(err)
}
if r.String() != want || !r.IsSource() || r.IsContainer() {
t.Errorf("%s: %q source=%v container=%v", src.Kind, r.String(), r.IsSource(), r.IsContainer())
}
back, err := Parse(want)
if err != nil || !reflect.DeepEqual(back, r) {
t.Errorf("round trip %q: %+v, %v", want, back, err)
}
}
// Vault and scope links aren't source links.
for _, link := range []string{"lazysecrets://azure/" + kvHost, "lazysecrets://databricks/" + dbHost + "/app"} {
if r, _ := Parse(link); r.IsSource() || !r.IsContainer() {
t.Errorf("%s: source=%v", link, r.IsSource())
}
}
}
func TestRepoLinks(t *testing.T) {
src := provider.Source{Kind: provider.KindRepo, Detail: "git.kebcloud.no/keb/nix-config"}
c := provider.Container{Source: src, Name: "secrets/server.yaml"}
r, err := ForObject(provider.Object{Container: c, Name: "db.password", Type: provider.TypeSecret}, "")
if err != nil {
t.Fatal(err)
}
link := r.String()
if link != "lazysecrets://repo/git.kebcloud.no%2Fkeb%2Fnix-config/secrets%2Fserver.yaml/db.password" {
t.Errorf("link %s", link)
}
back, err := Parse(link)
if err != nil || back.Kind != provider.KindRepo || back.Host != "git.kebcloud.no/keb/nix-config" ||
back.Scope != "secrets/server.yaml" || back.Name != "db.password" {
t.Errorf("parsed %+v, %v", back, err)
}
if fr, _ := ForSource(src); !fr.IsSource() || fr.String() != "lazysecrets://repo/git.kebcloud.no%2Fkeb%2Fnix-config" {
t.Errorf("source link %s", fr.String())
}
if cr, _ := ForContainer(c, provider.TypeSecret); !cr.IsContainer() || cr.ContainerName() != "secrets/server.yaml" {
t.Errorf("file link %+v", cr)
}
}
+301
View File
@@ -0,0 +1,301 @@
// Package store keeps what lazysecrets remembers between runs in one JSON
// file in the XDG state directory: a record per subscription, vault or
// secret the user has starred, annotated or used, keyed by its
// lazysecrets:// link, and the layout. It never holds secret values.
//
// JSON rather than YAML (the config's format) because the app writes it,
// and so an editor plugin can read it with a built-in parser
// (vim.json.decode).
package store
import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"sync"
"time"
)
// Node is what's remembered about one subscription, vault or secret. Name,
// Path, Source and Account are context, so lists of nodes (favourites,
// recent) can show and jump to them before their subscription is loaded.
type Node struct {
Name string `json:"name,omitempty"`
Path []string `json:"path,omitempty"` // parents' names: [source] or [source, vault]
Source string `json:"source,omitempty"` // ID of the subscription or workspace profile
Account string `json:"account,omitempty"` // provider ID that last read it
// StarredBy lists the accounts (provider IDs) the item is starred
// for: a star is the item through that account. A note describes the
// item itself, whichever account shows it.
StarredBy []string `json:"starredBy,omitempty"`
Note string `json:"note,omitempty"`
LastUsed time.Time `json:"lastUsed,omitzero"`
Uses int `json:"uses,omitempty"`
}
// keep reports whether the record holds anything worth keeping; context
// alone isn't.
func (n Node) keep() bool { return len(n.StarredBy) > 0 || n.Note != "" || n.Uses > 0 }
// StarredFor reports whether the item is starred for account.
func (n Node) StarredFor(account string) bool { return slices.Contains(n.StarredBy, account) }
// ToggleStar stars or unstars the item for account, reporting whether
// it's starred now.
func (n *Node) ToggleStar(account string) bool {
if i := slices.Index(n.StarredBy, account); i >= 0 {
n.StarredBy = slices.Delete(n.StarredBy, i, i+1)
return false
}
n.StarredBy = append(n.StarredBy, account)
return true
}
// UpdateCheck is the last check for a newer release.
func (s *Store) UpdateCheck() UpdateCheck {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.Update
}
// SetUpdateCheck records a check for a newer release.
func (s *Store) SetUpdateCheck(c UpdateCheck) error {
return s.save(func(d *file) { d.Update = c })
}
// Layout is the user's pane sizing, as offsets from the default layout so
// it adapts to the terminal size.
type Layout struct {
Split int `json:"split,omitempty"` // columns the divider moved
Heights [3]int `json:"heights,omitzero"` // rows added to each left pane
}
type file struct {
Version int `json:"version"`
Nodes map[string]Node `json:"nodes"`
Layout Layout `json:"layout,omitzero"`
// Flags are one-off choices, like "don't ask again".
Flags map[string]bool `json:"flags,omitempty"`
// Update is the last check for a newer release.
Update UpdateCheck `json:"update,omitzero"`
}
// UpdateCheck is the last look for a newer release: when, and what the
// latest was (its tag, changelog and page), so the next start within a
// day shows it without asking again.
type UpdateCheck struct {
At time.Time `json:"at,omitzero"`
Tag string `json:"tag,omitempty"`
Notes string `json:"notes,omitempty"`
URL string `json:"url,omitempty"`
}
const version = 1
type Store struct {
path string
mu sync.Mutex
data file
}
// Load reads path; a missing file is an empty store. path "" gives a store
// that never saves, for tests and --demo.
func Load(path string) (*Store, error) {
s := &Store{path: path}
d, err := read(path)
if err != nil {
return nil, err
}
s.data = d
return s, nil
}
func read(path string) (file, error) {
d := file{Version: version, Nodes: map[string]Node{}}
if path == "" {
return d, nil
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return d, nil
}
if err != nil {
return d, err
}
if err := json.Unmarshal(b, &d); err != nil {
return d, fmt.Errorf("%s: %w", path, err)
}
if d.Version > version {
return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version)
}
if d.Nodes == nil {
d.Nodes = map[string]Node{}
}
return d, nil
}
// Node returns the record for key, empty if there is none.
func (s *Store) Node(key string) Node {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.Nodes[key]
}
// Has reports whether there's a record for key.
func (s *Store) Has(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.data.Nodes[key]
return ok
}
// Nodes returns a copy of all records.
func (s *Store) Nodes() map[string]Node {
s.mu.Lock()
defer s.mu.Unlock()
return maps.Clone(s.data.Nodes)
}
// Update changes the record for key and saves. Another lazysecrets may
// have saved in the meantime, so the file is read again first and only
// this record is changed in it. A record left with nothing worth keeping
// is removed.
func (s *Store) Update(key string, change func(*Node)) error {
return s.save(func(d *file) {
n := d.Nodes[key]
change(&n)
if n.keep() {
d.Nodes[key] = n
} else {
delete(d.Nodes, key)
}
})
}
// Usage records — ones kept only for their use — are pruned when they
// go unused this long, and beyond this many, oldest first.
const (
usageMaxAge = 90 * 24 * time.Hour
usageMax = 100
)
// Use counts a use of key at the given time, with change updating its
// context, and prunes old usage records.
func (s *Store) Use(key string, at time.Time, change func(*Node)) error {
return s.save(func(d *file) {
n := d.Nodes[key]
change(&n)
n.Uses++
n.LastUsed = at
d.Nodes[key] = n
prune(d.Nodes, at)
})
}
// prune drops usage records unused since usageMaxAge before now, and the
// oldest beyond usageMax. Starred and noted records stay.
func prune(nodes map[string]Node, now time.Time) {
var usage []string
for k, n := range nodes {
if len(n.StarredBy) > 0 || n.Note != "" {
continue
}
if now.Sub(n.LastUsed) > usageMaxAge {
delete(nodes, k)
continue
}
usage = append(usage, k)
}
if len(usage) <= usageMax {
return
}
slices.SortFunc(usage, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
for _, k := range usage[usageMax:] {
delete(nodes, k)
}
}
// Flag reports a one-off choice.
func (s *Store) Flag(name string) bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.Flags[name]
}
// SetFlag records a one-off choice.
func (s *Store) SetFlag(name string, on bool) error {
return s.save(func(d *file) {
if d.Flags == nil {
d.Flags = map[string]bool{}
}
if on {
d.Flags[name] = true
} else {
delete(d.Flags, name)
}
})
}
func (s *Store) Layout() Layout {
s.mu.Lock()
defer s.mu.Unlock()
return s.data.Layout
}
func (s *Store) SetLayout(l Layout) error {
return s.save(func(d *file) { d.Layout = l })
}
// save applies change to the file as it is now on disk (or to memory, for
// an unsaved store) and writes it atomically, readable by the user only.
func (s *Store) save(change func(*file)) error {
s.mu.Lock()
defer s.mu.Unlock()
d := s.data
if s.path != "" {
var err error
if d, err = read(s.path); err != nil {
return err
}
} else {
d.Nodes = maps.Clone(d.Nodes)
}
change(&d)
d.Version = version
if s.path != "" {
b, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
if err := writeAtomic(s.path, append(b, '\n')); err != nil {
return err
}
}
s.data = d
return nil
}
func writeAtomic(path string, b []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // no-op after a successful rename
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
+114
View File
@@ -0,0 +1,114 @@
package store
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
const key = "lazysecrets://azure/kv.vault.azure.net/secrets/db-password"
func TestUpdateAndReload(t *testing.T) {
path := filepath.Join(t.TempDir(), "state", "state.json")
s, err := Load(path)
if err != nil {
t.Fatal(err)
}
if err := s.Update(key, func(n *Node) {
n.Name, n.Path, n.StarredBy, n.Note = "db-password", []string{"prod", "kv"}, []string{"Azure"}, "prod SQL admin"
}); err != nil {
t.Fatal(err)
}
if err := s.SetLayout(Layout{Split: 8}); err != nil {
t.Fatal(err)
}
again, err := Load(path)
if err != nil {
t.Fatal(err)
}
if n := again.Node(key); !n.StarredFor("Azure") || n.Note != "prod SQL admin" || n.Path[1] != "kv" {
t.Errorf("reloaded %+v", n)
}
if again.Layout().Split != 8 {
t.Error("layout lost")
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o600 {
t.Errorf("mode %o", info.Mode().Perm())
}
}
func TestContextAloneIsntKept(t *testing.T) {
s, _ := Load("")
_ = s.Update(key, func(n *Node) { n.StarredBy, n.Name = []string{"Azure"}, "db-password" })
_ = s.Update(key, func(n *Node) { n.StarredBy = nil })
if _, ok := s.Nodes()[key]; ok {
t.Error("a record with only context left was kept")
}
}
func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
a, _ := Load(path)
b, _ := Load(path)
_ = a.Update("one", func(n *Node) { n.StarredBy = []string{"Azure"} })
_ = b.Update("two", func(n *Node) { n.Note = "hi" }) // b hasn't seen "one"
_ = a.Update("one", func(n *Node) { n.Uses++; n.LastUsed = time.Now() })
final, _ := Load(path)
if n := final.Node("one"); !n.StarredFor("Azure") || n.Uses != 1 {
t.Errorf("one = %+v", n)
}
if final.Node("two").Note != "hi" {
t.Error("b's change lost")
}
}
func TestNewerVersionRefused(t *testing.T) {
path := filepath.Join(t.TempDir(), "state.json")
_ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600)
if _, err := Load(path); err == nil {
t.Error("a newer file was accepted")
}
}
func TestUseCountsAndPrunes(t *testing.T) {
s, _ := Load("")
now := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
ctx := func(n *Node) { n.Name = "x" }
_ = s.Use("old", now.Add(-100*24*time.Hour), ctx)
_ = s.Update("old-starred", func(n *Node) { n.StarredBy = []string{"Azure"}; n.Uses = 1 })
_ = s.Use("a", now.Add(-time.Hour), ctx)
_ = s.Use("a", now, ctx)
if n := s.Node("a"); n.Uses != 2 || !n.LastUsed.Equal(now) || n.Name != "x" {
t.Errorf("a = %+v", n)
}
if s.Has("old") {
t.Error("record unused for 100 days kept")
}
if !s.Has("old-starred") {
t.Error("starred record pruned")
}
for i := range usageMax + 5 {
_ = s.Use(fmt.Sprint("n", i), now.Add(time.Duration(i)*time.Minute), ctx)
}
if got := len(s.Nodes()); got != usageMax+1 { // + old-starred
t.Errorf("%d records, want %d", got, usageMax+1)
}
if s.Has("n0") || !s.Has(fmt.Sprint("n", usageMax+4)) {
t.Error("pruned the wrong end")
}
}
func TestStarsPerAccount(t *testing.T) {
var n Node
if !n.ToggleStar("Azure:alice") || !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") {
t.Errorf("star for alice: %v", n.StarredBy)
}
n.ToggleStar("Azure:bob")
if n.ToggleStar("Azure:alice") || n.StarredFor("Azure:alice") || !n.StarredFor("Azure:bob") {
t.Errorf("unstar alice: %v", n.StarredBy)
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+50
View File
@@ -0,0 +1,50 @@
package app
import (
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// cacheTTL is how long cached metadata is shown without reloading. Older
// entries are still shown immediately, then reloaded in the background.
const cacheTTL = 2 * time.Minute
// cache holds metadata for this session only. It never holds secret values
// or errors, and is never written to disk.
type cache struct {
containers map[string]entry[[]provider.Container] // by source key
objects map[string]entry[[]provider.Object] // by container key (ID|type)
details map[string]entry[provider.Object] // Describe results by ID@version
// cursor remembers the selected child per parent key, so returning to
// a subscription or vault puts the cursor back where it was.
cursor map[string]string
}
type entry[T any] struct {
value T
at time.Time
}
func newCache() cache {
return cache{
containers: map[string]entry[[]provider.Container]{},
objects: map[string]entry[[]provider.Object]{},
details: map[string]entry[provider.Object]{},
cursor: map[string]string{},
}
}
func (e entry[T]) fresh(now time.Time) bool { return now.Sub(e.at) < cacheTTL }
// detailKey includes the account: the result carries its container, and
// later requests for the object are routed by it.
func detailKey(o provider.Object) string {
return containerKey(o.Container) + "|" + o.ID + "@" + o.Version
}
func (c cache) rememberCursor(parent, child string) {
if parent != "" && child != "" {
c.cursor[parent] = child
}
}
+161
View File
@@ -0,0 +1,161 @@
package app
import (
"context"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// counting records how often lists and details are fetched.
type counting struct {
provider.Provider
containers, objects, describes, values, versions int
}
func (c *counting) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
c.versions++
return c.Provider.Versions(ctx, o)
}
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
c.containers++
return c.Provider.Containers(ctx, src)
}
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
c.objects++
return c.Provider.Objects(ctx, ct, t)
}
func (c *counting) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
c.describes++
return c.Provider.Describe(ctx, o)
}
func (c *counting) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
c.values++
return c.Provider.Value(ctx, o)
}
func newCachingDriver(t *testing.T) (*driver, *counting, *time.Time) {
t.Helper()
p := &counting{Provider: fake.Azure(0)}
d := newDriver(t, p)
// Entries from the initial load were stamped with the real clock.
clock := time.Now()
d.m.now = func() time.Time { return clock }
return d, p, &clock
}
func TestCacheAvoidsRefetchAndRestoresCursor(t *testing.T) {
t.Parallel()
d, p, _ := newCachingDriver(t)
d.keys("2", "j", "3", "j") // kv-shared-prod... then back
d.keys("2", "k", "3", "j") // kv-app-prod, cursor to api-key
d.keys("1", "j", "k") // away to platform-dev and back
before := *p
d.keys("j", "k")
if p.containers != before.containers || p.objects != before.objects {
t.Errorf("fresh cache refetched: containers %d→%d, objects %d→%d",
before.containers, p.containers, before.objects, p.objects)
}
if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" {
t.Errorf("cursor not restored: %s/%s", c, s)
}
}
func TestStaleCacheShownThenReloaded(t *testing.T) {
t.Parallel()
d, p, clock := newCachingDriver(t)
d.keys("j") // platform-dev
*clock = clock.Add(cacheTTL + time.Second)
before := p.containers
d.keys("k") // back to platform-prod: stale
if p.containers != before+1 {
t.Errorf("stale entry not reloaded: %d calls, want %d", p.containers, before+1)
}
if _, c, _ := d.selected(); c != "kv-app-prod" {
t.Errorf("selection lost across background reload: %q", c)
}
}
func TestRefreshBypassesCache(t *testing.T) {
t.Parallel()
d, p, _ := newCachingDriver(t)
before := p.objects
d.keys("3", "r")
if p.objects != before+1 {
t.Errorf("r did not reload: %d calls, want %d", p.objects, before+1)
}
}
func TestValuesAndErrorsNotCached(t *testing.T) {
t.Parallel()
d, p, _ := newCachingDriver(t)
d.keys("space", "space", "space")
if p.values != 2 {
t.Errorf("value fetched %d times for two reveals, want 2", p.values)
}
d.keys("2", "G") // kv-restricted: 403
if _, ok := d.m.cache.objects[d.m.selContainer]; ok {
t.Error("error result cached")
}
before := p.objects
d.keys("k", "j")
if p.objects != before+2 {
t.Errorf("failed list not retried: %d calls, want %d", p.objects, before+2)
}
}
func TestSignInDropsCache(t *testing.T) {
t.Parallel()
d, _, _ := newCachingDriver(t)
d.send(loginDoneMsg{})
// Everything is reloaded fresh, so only the current selection is cached.
if n := len(d.m.cache.containers); n != 1 {
t.Errorf("containers cached after sign-in = %d, want 1", n)
}
}
// runAll runs cmd and the commands it batches, ignoring their messages.
func runAll(cmd tea.Cmd) {
if cmd == nil {
return
}
if b, ok := cmd().(tea.BatchMsg); ok {
for _, c := range b {
runAll(c)
}
}
}
func TestMovingOnDropsUnsettledLoads(t *testing.T) {
t.Parallel()
c := &counting{Provider: fake.Azure(0)}
d := newDriverOpts(t, Options{}, c)
d.m.settle = 20 * time.Millisecond
before := c.containers
// j to platform-dev and straight back: its load never goes out.
m1, passed := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
_, back := m1.Update(tea.KeyPressMsg{Code: 'k', Text: "k"})
runAll(back)
runAll(passed)
if c.containers != before {
t.Errorf("passing over a subscription loaded it: %d calls", c.containers-before)
}
// Stopping on it loads it once the cursor settles.
_, stop := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
runAll(stop)
if c.containers != before+1 {
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
}
}
+318
View File
@@ -0,0 +1,318 @@
package app
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// Custom commands (customCommands in the config) run a shell command on
// the selected item: by their key in the pane they're for, or from the
// commands menu (x). Prompts ask for text first; commands that get the
// secret value ask before running. The value reaches the command on its
// input or in an environment variable, never on its command line.
// commandTimeout bounds commands whose output is captured; terminal ones
// run as long as the user likes.
const commandTimeout = 5 * time.Minute
type commandValueMsg struct {
cmd commands.Command
rendered string
value provider.SecretValue
err error
}
type commandDoneMsg struct {
cmd commands.Command
out string
err error
}
// paneContext is the custom command context of a pane.
func paneContext(p pane) string {
switch p {
case paneSources:
return commands.Sources
case paneContainers:
return commands.Vaults
}
return commands.Secrets
}
// commandsHere are the custom commands for the focused pane.
func (m *Model) commandsHere() []commands.Command {
var out []commands.Command
for _, c := range m.commands {
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
out = append(out, c)
}
}
return out
}
// commandForKey is the custom command a key runs here, if any.
func (m *Model) commandForKey(msg tea.KeyPressMsg) (commands.Command, bool) {
for _, c := range m.commandsHere() {
if c.Key != "" && c.Key == msg.String() {
return c, true
}
}
return commands.Command{}, false
}
// showCommands lists the custom commands for the focused pane.
func (m *Model) showCommands() tea.Cmd {
cmds := m.commandsHere()
if len(cmds) == 0 {
m.flash = warning("no custom commands for the " + strings.ToLower(paneNames[m.focus]) + " pane: see customCommands in the config")
return nil
}
var opts []option
for _, c := range cmds {
label := c.Description
if label == "" {
label = c.Command
}
if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
}
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
}
return m.showFilterMenu("Commands", opts)
}
// startCommand runs c on the selection: prompts, then confirmation, then
// the command.
func (m *Model) startCommand(c commands.Command) tea.Cmd {
it, obj, ok := m.commandItem(c.Context)
if !ok {
m.flash = warning(map[string]string{
commands.Sources: "select a subscription or workspace to run " + c.Description + " on",
commands.Vaults: "select a vault or scope to run " + c.Description + " on",
commands.Secrets: "select a secret to run " + c.Description + " on",
}[c.Context])
return nil
}
it.Prompt = map[string]commands.Q{}
return m.askPrompt(c, it, obj, 0)
}
// askPrompt asks c's prompts in turn, then confirms or runs it.
func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Object, i int) tea.Cmd {
if i == len(c.Prompts) {
return m.confirmCommand(c, it, obj)
}
p := c.Prompts[i]
in := newTextInput(p.Title+": ", 48)
in.SetValue(p.Default)
in.CursorEnd()
m.prompt = &modal{
title: cmpOr(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1)
},
footer: footer(hint(m.keys.PopupSelect, "next"), hint(m.keys.PopupClose, "cancel")),
}
return m.prompt.input.Focus()
}
func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provider.Object) tea.Cmd {
rendered, err := c.Render(it)
if err != nil {
m.flash = flash{text: c.Description + ": " + err.Error(), isErr: true}
return nil
}
if !c.Confirms() {
return m.runCommand(c, rendered, obj)
}
var lines []string
for _, l := range strings.Split(ansi.Wrap(rendered, 70, " "), "\n") {
lines = append(lines, m.st.Muted.Render(l))
}
switch {
case c.Value == "stdin":
lines = append(lines, "", "It gets the value of "+string(it.Name)+" on its input.")
case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
}
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
})
return nil
}
// runCommand fetches the value if the command takes it, then runs it.
func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Object) tea.Cmd {
if c.Value == "" || obj == nil {
return m.execCommand(c, rendered, provider.SecretValue{})
}
o := *obj
p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout
m.usedObject(o)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
v, err := p.Value(ctx, o)
return commandValueMsg{cmd: c, rendered: rendered, value: v, err: err}
}
}
// execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) {
switch {
case c.Value == "stdin":
cmd.Stdin = strings.NewReader(value.Reveal())
case strings.HasPrefix(c.Value, "env:"):
cmd.Env = append(os.Environ(), strings.TrimPrefix(c.Value, "env:")+"="+value.Reveal())
}
}
if c.Out() == commands.Terminal {
// Pause before coming back, reading the terminal itself: the
// command's input may be taken.
script := rendered + "\nprintf '\\n%s' 'Press enter to return to lazysecrets'; read -r _ </dev/tty"
cmd := exec.Command(shell, "-c", script)
setValue(cmd)
m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
}
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, shell, "-c", rendered)
setValue(cmd)
out, err := cmd.CombinedOutput()
return commandDoneMsg{cmd: c, out: string(out), err: err}
}
}
func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
if msg.err != nil {
m.noteErr(msg.err)
m.flash = flash{text: msg.cmd.Description + ": " + msg.err.Error(), isErr: true}
return nil
}
return m.execCommand(msg.cmd, msg.rendered, msg.value)
}
// maxOutputLines caps what a popup shows of a command's output.
const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmpOr(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch {
case msg.err != nil:
out := strings.TrimSpace(msg.out)
if i := strings.LastIndexByte(out, '\n'); i >= 0 {
out = out[i+1:] // the last line, usually the error
}
m.flash = flash{text: strings.TrimSpace(name + " failed: " + msg.err.Error() + " " + out), isErr: true}
default:
m.flash = done(name)
}
return
}
lines := strings.Split(strings.TrimRight(ansi.Strip(msg.out), "\n"), "\n")
if len(lines) > maxOutputLines {
lines = append(lines[:maxOutputLines], m.st.Muted.Render(fmt.Sprintf("… %d more lines", len(lines)-maxOutputLines)))
}
title := "✓ " + name
if msg.err != nil {
title = "✗ " + name + ": " + msg.err.Error()
}
m.showMenu(title, lines, nil)
}
// commandItem fills a command's fields from the selection: the source,
// and the vault and secret as deep as the context goes (global: as deep
// as the focus). ok is false if the context's item isn't selected.
func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool) {
depth := map[string]int{commands.Sources: 0, commands.Vaults: 1, commands.Secrets: 2}[ctx]
if ctx == commands.Global {
depth = min(int(m.focus), 2)
}
var it commands.Item
src, ok := m.selectedSource()
if !ok && depth == 0 {
return it, nil, false
}
if ok {
it.Kind, it.Account = commands.Q(strings.ToLower(string(src.Kind))), commands.Q(accountOf(src))
it.Source, it.SourceID, it.URL = commands.Q(src.Name), commands.Q(src.ID), commands.Q(src.URL)
it.Note = commands.Q(m.store.Node(sourceKey(src)).Note)
if r, err := ref.ForSource(src); err == nil {
it.Link = commands.Q(r.String())
}
}
if depth == 0 {
return it, nil, true
}
c, ok := m.containers.Selected()
if !ok {
return it, nil, ctx == commands.Global
}
it.Vault, it.URL, it.Note = commands.Q(c.Name), commands.Q(c.URL), commands.Q(m.store.Node(storeKey(c)).Note)
if c.Source.Kind == provider.KindAzure {
it.VaultURL = commands.Q(c.Detail)
}
if r, err := ref.ForContainer(c, m.objType); err == nil {
it.Link = commands.Q(r.String())
}
if depth == 1 {
return it, nil, true
}
o, ok := m.detail.Secret()
if !ok {
return it, nil, ctx == commands.Global
}
version := ""
if m.versionsOf != nil {
version = o.Version // run from the versions view: that version
}
it.Name, it.Type, it.Version = commands.Q(o.Name), commands.Q(strings.ToLower(string(o.Type))), commands.Q(version)
it.Reference, it.URL, it.Note = commands.Q(o.Reference), commands.Q(o.URL), commands.Q(m.store.Node(objectKey(o)).Note)
if !o.Expires.IsZero() {
it.Expires = commands.Q(o.Expires.Format(time.RFC3339))
}
if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String())
}
if version != "" {
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true
}
func newTextInput(prompt string, width int) textinput.Model {
in := textinput.New()
in.Prompt = prompt
in.SetWidth(width)
return in
}
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
)
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
t.Setenv("SHELL", "/bin/sh")
d := newDriverOpts(t, Options{Commands: cmds})
d.wait = 5 * time.Second
return d
}
func TestCommandGetsValueOnInput(t *testing.T) {
out := filepath.Join(t.TempDir(), "out")
d := commandDriver(t, commands.Command{
Key: "K", Context: commands.Secrets, Description: "Save it",
Command: "cat > " + out + " && echo {{.Name}} {{.Prompt.ns}} >> " + out,
Value: "stdin", Prompts: []commands.Prompt{{Name: "ns", Title: "Namespace", Default: "team a"}},
})
d.keys("3", "K") // db-password
if !strings.Contains(d.screen(), "Namespace: team a") {
t.Fatalf("no prompt:\n%s", d.screen())
}
d.keys("enter")
scr := d.screen()
// The command as shown, unwrapped: where it wraps depends on the
// temporary path's length.
shown := ""
if d.m.prompt != nil {
shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
}
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
}
if strings.Contains(scr, "correct-horse") {
t.Fatal("value shown in the confirmation")
}
d.keys("y")
b, err := os.ReadFile(out)
if err != nil {
t.Fatalf("command didn't run: %v\n%s", err, d.screen())
}
if got := string(b); got != "correct-horse-battery-staple"+"db-password team a\n" {
t.Errorf("command got %q", got)
}
if !strings.Contains(d.screen(), "✓ Save it") {
t.Errorf("no confirmation message:\n%s", d.screen())
}
}
func TestCommandPopupAndMenu(t *testing.T) {
d := commandDriver(t,
commands.Command{Context: commands.Vaults, Description: "Show vault", Command: "echo vault={{.Vault}} url={{.VaultURL}}", Output: commands.Popup},
commands.Command{Context: commands.Secrets, Description: "Secrets only", Command: "true"},
)
d.keys("2", "x")
if !strings.Contains(d.screen(), "Show vault") || strings.Contains(d.screen(), "Secrets only") {
t.Fatalf("commands menu for the Vaults pane:\n%s", d.screen())
}
d.keys("enter")
if !strings.Contains(d.screen(), "vault=kv-app-prod url=https://kv-app-prod.vault.azure.net") {
t.Errorf("popup output:\n%s", d.screen())
}
}
func TestCommandNeedsItsSelection(t *testing.T) {
d := commandDriver(t, commands.Command{Key: "K", Context: commands.Secrets, Description: "Secret thing", Command: "true"})
d.keys("1", "K") // Sources pane: not a secrets command's context
if d.m.prompt != nil {
t.Error("ran from the wrong pane")
}
}
+55
View File
@@ -0,0 +1,55 @@
package app
import (
"fmt"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Secrets, keys and certificates with an expiry date are marked in the
// list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState int
const (
notExpiring expiryState = iota
expiresSoon
expired
)
// expiry tells whether o has expired or will within the warning period,
// and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
if o.Expires.IsZero() {
return notExpiring, 0
}
left := o.Expires.Sub(m.now())
switch {
case left <= 0:
return expired, 0
case left <= m.expiryWarning:
return expiresSoon, left
}
return notExpiring, left
}
// expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state {
case expired:
return "expired"
case expiresSoon:
return "expiring"
}
return ""
}
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+245
View File
@@ -0,0 +1,245 @@
package app
import (
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// What lazysecrets remembers about subscriptions, vaults and secrets —
// stars, notes — lives in the store, one record per item keyed by its
// lazysecrets:// link. Keys leave out the account, so a star or note on a
// subscription shows under every account that sees it.
const star = "★ "
func sourceKey(s provider.Source) string {
if r, err := ref.ForSource(s); err == nil {
return r.String()
}
return "source:" + string(s.Kind) + "/" + s.ID
}
func storeKey(c provider.Container) string {
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
return r.String()
}
return "container:" + c.ID
}
func objectKey(o provider.Object) string {
if r, err := ref.ForObject(o, ""); err == nil {
return r.String()
}
return "object:" + o.ID
}
// remember records context with a change, so lists of remembered items
// can show and jump to them later.
func (m *Model) remember(key, name string, path []string, src provider.Source, change func(*store.Node)) error {
ctx := withContext(name, path, src)
return m.store.Update(key, func(n *store.Node) {
ctx(n)
change(n)
})
}
// withContext sets a record's context: where the item is, and the account
// that read it. A vault opened from a link has no known subscription;
// that keeps the one already recorded.
func withContext(name string, path []string, src provider.Source) func(*store.Node) {
return func(n *store.Node) {
n.Name = name
if src.ID != "" || n.Source == "" {
n.Path, n.Source = path, src.ID
}
if src.Provider != "" {
n.Account = src.Provider
}
}
}
// refreshContext updates a remembered item's context after reaching it,
// e.g. the account that could read it. Items without a record are left
// alone, so browsing doesn't write the file.
func (m *Model) refreshContext(key, name string, path []string, src provider.Source) {
if m.store.Has(key) && src.Provider != "" {
_ = m.remember(key, name, path, src, func(*store.Node) {})
}
}
// containerRender draws a vault or scope, starred or not. In the
// Opened links list it also names the source, since they come from many.
func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string {
st, iconSet := m.store, m.icons
return func(sty styles.Styles, c provider.Container, width int) string {
n := st.Node(storeKey(c))
line := c.Name
if c.Source.Kind == provider.KindRepo {
// A secret file: a lock for an encrypted one, a file in the
// warning colour for plain text, and its type after it
// ("sops · age", "env · plain text").
icon, style := iconSet.Encrypted, sty.KindRepo
if !strings.HasPrefix(c.Detail, "sops") {
icon, style = iconSet.PlainFile, sty.Warning
}
if icon != "" {
line = style.Render(icon) + " " + line
}
line += sty.Muted.Render(" " + c.Detail)
}
if n.StarredFor(c.Source.Provider) {
line = sty.Favourite.Render(star) + line
}
if withSource {
src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
src = account + " / " + src
}
line += sty.Muted.Render(" · " + src)
}
return withNote(sty, line, n.Note, width)
}
}
func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
st := m.store
return func(sty styles.Styles, o provider.Object, width int) string {
n := st.Node(objectKey(o))
line := o.Name
if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)")
}
if n.StarredFor(o.Container.Source.Provider) {
line = sty.Favourite.Render(star) + line
}
switch state, left := m.expiry(o); state {
case expired:
line += " " + sty.Error.Render("✗ expired")
case expiresSoon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
}
return withNote(sty, line, n.Note, width)
}
}
// starredContainersFirst moves starred vaults and scopes to the front,
// keeping the order otherwise. It doesn't modify items, which may be cached.
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Container) int {
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out
}
// starredFirst moves starred objects to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached.
func (m Model) starredFirst(items []provider.Object) []provider.Object {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Object) int {
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
})
return out
}
// showLinkedContainers lists the vaults opened from links.
func (m *Model) showLinkedContainers() {
m.containers.Title, m.containers.Empty = "[2] Opened links", "no links opened"
m.containers.SetRender(m.containerRender(true))
m.containers.SetItems(m.linked)
m.containers.Select(m.cache.cursor[linksRowID])
}
// rebind records the account that served a vault opened from a link. The
// container's key includes the account, so the selection moves to the
// new key without reloading. It returns the new key for the object list.
func (m *Model) rebind(c provider.Container) string {
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
for i := range m.linked {
if m.linked[i].ID == c.ID {
m.linked[i].Source = c.Source
}
}
key := containerKey(c) + "|" + string(m.objType)
if m.onLinks() {
m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c))
m.selContainer = key
}
return key
}
// resortStarred puts starred items first again in every pane, after
// stars changed elsewhere (the favourites list).
func (m *Model) resortStarred() {
m.rebuildSources()
if !m.onLinks() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
}
if m.versionsOf == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
// toggleFavourite stars or unstars the selected subscription (Sources),
// vault or scope (Vaults) or object (Secrets and detail panes).
func (m *Model) toggleFavourite() tea.Cmd {
var name string
var on bool
// A star is the item through the account showing it.
var err error
switch m.focus {
case paneSources:
src, ok := m.selectedSource()
if !ok {
m.flash = warning("select a subscription or workspace to star")
return nil
}
name = src.Name
toggle := func(n *store.Node) { on = n.ToggleStar(src.Provider) }
if err = m.remember(sourceKey(src), src.Name, nil, src, toggle); err == nil {
m.rebuildSources() // re-sorts; the cursor follows the source
}
case paneContainers:
c, ok := m.containers.Selected()
if !ok {
return nil
}
name = c.Name
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(storeKey(c), c.Name, []string{c.Source.Name}, c.Source, toggle); err == nil {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
}
default:
if m.versionsOf != nil {
m.flash = warning("star the item from the list rather than a version")
return nil
}
o, ok := m.detail.Secret()
if !ok {
return nil
}
name = o.Name
c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
switch {
case err != nil:
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
case on:
m.flash = done("starred " + name)
default:
m.flash = done("unstarred " + name)
}
return nil
}
+435
View File
@@ -0,0 +1,435 @@
package app
import (
"context"
"path/filepath"
"slices"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// jump opens the favourites list, filters it and jumps to the first match.
func (d *driver) jump(filter string) {
d.t.Helper()
d.keys("F")
if d.m.search == nil || d.m.search.scope().id != scopeFavs {
d.t.Fatalf("F didn't open Go to on Favourites:\n%s", d.screen())
}
for _, r := range filter {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.keys("enter")
}
func TestStarVaultSortsFirstAndJumps(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j", "f") // star kv-shared-prod
if items := d.m.containers.Items(); items[0].Name != "kv-shared-prod" {
t.Errorf("starred vault not first: %s", items[0].Name)
}
if !strings.Contains(d.screen(), "★ kv-shared-prod") {
t.Errorf("no star:\n%s", d.screen())
}
if strings.Contains(d.screen(), "Favourites (") {
t.Error("the old Favourites row is back")
}
d.keys("1", "j") // elsewhere
d.keys("F")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod") {
t.Fatalf("favourites list:\n%s", d.screen())
}
d.keys("esc")
d.jump("shared")
if src, c, _ := d.selected(); src != "platform-prod" || c != "kv-shared-prod" {
t.Errorf("jumped to %s/%s", src, c)
}
if d.m.focus != paneContainers {
t.Errorf("focus %d after jumping to a vault", d.m.focus)
}
}
func TestJumpToStarredSourceAndSecret(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "j", "f") // star api-key in platform-prod / kv-app-prod
d.keys("1", "j", "f") // star platform-dev (it moves to the top of its group)
d.keys("j", "j", "j") // down to Databricks prod
d.jump("platform-dev")
if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers {
t.Errorf("source jump: %s, focus %d", src, d.m.focus)
}
d.jump("api")
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Errorf("secret jump: %s/%s/%s", src, c, s)
}
}
func TestFavouritesListEmpty(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F")
if d.m.search == nil || !strings.Contains(d.screen(), "nothing starred yet") {
t.Errorf("empty favourites:\n%s", d.screen())
}
}
func TestStarSecretSortsFirst(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "j", "f") // star api-key
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("cursor left the starred item: %q", s)
}
rows := d.m.secrets.Items()
if rows[0].Name != "api-key" || !strings.Contains(d.screen(), "★ api-key") {
t.Errorf("starred secret not first: %v\n%s", rows[0].Name, d.screen())
}
// Still first after leaving and coming back (cached list).
d.keys("2", "j", "k")
if d.m.secrets.Items()[0].Name != "api-key" {
t.Error("starred secret not first after returning")
}
}
func TestNoStarringVersions(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("v", "f")
if d.m.store.Node(objectKey(*d.m.versionsOf)).StarredFor("Azure") || !strings.Contains(d.screen(), "rather than a version") {
t.Errorf("starring a version:\n%s", d.screen())
}
}
func TestFavouritesPersist(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "state.json")
load := func() *store.Store {
s, err := store.Load(path)
if err != nil {
t.Fatal(err)
}
return s
}
d := newDriverOpts(t, Options{Store: load()})
d.keys("2", "f", "3", "j", "f")
d2 := newDriverOpts(t, Options{Store: load()})
d2.keys("F")
if !strings.Contains(d2.screen(), "platform-prod / kv-app-prod") ||
!strings.Contains(d2.screen(), "platform-prod / kv-app-prod / api-key") {
t.Errorf("favourites not restored:\n%s", d2.screen())
}
d2.keys("esc")
if d2.m.secrets.Items()[0].Name != "api-key" {
t.Error("secret favourite not restored")
}
}
func TestStarSourceMovesItToTheTop(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("j", "f") // star platform-dev
if src, _, _ := d.selected(); src != "platform-dev" {
t.Errorf("cursor left the starred source: %q", src)
}
rows := d.m.sources.Items()
if rows[1].source.Name != "platform-dev" || !strings.Contains(d.screen(), "★ platform-dev") {
t.Errorf("not first in its group: %q\n%s", rows[1].source.Name, d.screen())
}
// Flat: starred sources come before every other provider's.
// platform-dev now leads its group: platform-prod, header, prod, dev.
d.keys("j", "j", "j", "j", "f", "`") // also star Databricks dev
var flat []string
for _, r := range d.m.sources.Items() {
flat = append(flat, r.source.Name)
}
if strings.Join(flat[:2], ",") != "platform-dev,dev" {
t.Errorf("flat order = %v", flat)
}
d.keys("`", "k") // grouped again; dev leads its group, so k is the header
d.keys("f")
if !strings.Contains(d.screen(), "select a subscription or workspace to star") {
t.Errorf("starring a header:\n%s", d.screen())
}
}
// denying refuses to list objects, like an account without access.
type denying struct{ *counting }
func (d denying) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
d.objects++
return nil, fake.ErrForbidden
}
// kvKey is kv-app-prod's store key, kvSharedKey kv-shared-prod's.
const (
kvKey = "lazysecrets://azure/kv-app-prod.vault.azure.net"
kvSharedKey = "lazysecrets://azure/kv-shared-prod.vault.azure.net"
)
// starredWithoutAccount is kv-app-prod starred with no account recorded,
// like one starred by an account that's no longer signed in.
func starredWithoutAccount(t *testing.T) *store.Store {
t.Helper()
s, _ := store.Load("")
if err := s.Update(kvKey, func(n *store.Node) {
n.Name, n.Path, n.Source, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", []string{"Azure:carol"}
}); err != nil {
t.Fatal(err)
}
return s
}
func TestOldFavouriteFindsTheAccountWithAccess(t *testing.T) {
t.Parallel()
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
mem := starredWithoutAccount(t)
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, bob)
d.jump("kv-app") // both accounts see platform-prod: which one?
if !strings.Contains(d.screen(), "Several accounts can see kv-app-prod") {
t.Fatalf("no account choice:\n%s", d.screen())
}
d.keys("2") // bob
if _, c, s := d.selected(); c != "kv-app-prod" || s != "db-password" {
t.Fatalf("favourite not browsable: %q/%q\n%s", c, s, d.screen())
}
if got := mem.Node(kvKey).Account; got != "Azure:bob" {
t.Errorf("favourite bound to %q, want Azure:bob", got)
}
d.keys("R") // used through bob now
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod · bob") {
t.Errorf("recent should show the account:\n%s", d.screen())
}
d.keys("esc")
// Reads go through bob, and alice isn't asked again.
before := alice.objects
d.keys("space")
if bob.values != 1 || alice.values != 0 {
t.Errorf("reveal: alice %d, bob %d", alice.values, bob.values)
}
d.keys("2", "r")
if alice.objects != before {
t.Errorf("alice asked again after binding: %d → %d", before, alice.objects)
}
}
func TestOldFavouriteNoAccountHasAccess(t *testing.T) {
t.Parallel()
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
mem := starredWithoutAccount(t)
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, denying{bob})
d.jump("kv-app")
d.keys("1") // alice: no access
if !strings.Contains(d.screen(), "403 Forbidden") {
t.Errorf("no access error:\n%s", d.screen())
}
if mem.Node(kvKey).Account != "" {
t.Error("favourite bound although no account worked")
}
}
func TestFavouritesPopupKeepsItsSize(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("j", "f", "2", "j", "f", "3", "j", "f") // a subscription, a vault, a secret
d.keys("F")
size := func() (int, int) {
box := d.m.searchViewBox()
return lipgloss.Width(box), lipgloss.Height(box)
}
w0, h0 := size()
for _, k := range []string{"down", "down", "up"} {
d.send(tea.KeyPressMsg{Code: map[string]rune{"down": tea.KeyDown, "up": tea.KeyUp}[k]})
if w, h := size(); w != w0 || h != h0 {
t.Fatalf("popup changed from %dx%d to %dx%d moving %s", w0, h0, w, h, k)
}
}
d.send(tea.KeyPressMsg{Code: 'z', Text: "z"}) // filter down to nothing
if w, h := size(); w != w0 || h != h0 {
t.Errorf("popup changed from %dx%d to %dx%d while filtering", w0, h0, w, h)
}
}
func TestStoreKeysAreLinks(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("f", "2", "f", "3", "j", "f")
var keys []string
for k := range d.m.store.Nodes() {
keys = append(keys, k)
}
slices.Sort(keys)
want := []string{
"lazysecrets://azure/kv-app-prod.vault.azure.net",
"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key",
"lazysecrets://azure/subscriptions/sub-prod",
}
if !slices.Equal(keys, want) {
t.Errorf("keys = %v", keys)
}
n := d.m.store.Node(want[1])
if n.Name != "api-key" || n.Source != "sub-prod" || n.Account != "Azure" || !slices.Equal(n.Path, []string{"platform-prod", "kv-app-prod"}) {
t.Errorf("context = %+v", n)
}
}
func TestFavouritesShowNotes(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j", "f", "n") // star kv-shared-prod and note it
for _, r := range "shared certs" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.keys("enter", "F")
line := ""
for l := range strings.SplitSeq(d.screen(), "\n") {
if strings.Contains(l, "kv-shared-prod") && strings.Contains(l, "│") && strings.Contains(l, "/") {
line = l
}
}
if !strings.Contains(line, "shared certs") {
t.Fatalf("no note in the favourites row %q:\n%s", line, d.screen())
}
for _, r := range "certs" { // the filter matches notes
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
if len(d.m.search.shown) != 1 {
t.Errorf("filtering by note: %d entries", len(d.m.search.shown))
}
}
func TestSecretWithoutContextJumpsThroughItsVault(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
_ = mem.Update(kvKey, func(n *store.Node) {
n.Name, n.Path, n.Source, n.Account, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", "Azure", []string{"Azure"}
})
_ = mem.Update(kvKey+"/secrets/api-key", func(n *store.Node) { n.StarredBy = []string{"Azure"} })
// counting hides Locate, like an account that can't find the vault
// in Resource Graph: opening it as a link would land in Opened links.
d := newDriverOpts(t, Options{Store: mem}, &counting{Provider: fake.Azure(0)})
d.keys("j") // elsewhere
d.jump("api-key")
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Errorf("jumped to %s/%s/%s", src, c, s)
}
if strings.Contains(d.screen(), "Opened links") {
t.Errorf("went through Opened links:\n%s", d.screen())
}
}
func TestLinkedVaultKeepsKnownContext(t *testing.T) {
t.Parallel()
n := store.Node{Source: "sub-prod", Path: []string{"platform-prod", "kv-app-prod"}}
// A vault opened from a link: account known, subscription not.
withContext("api-key", []string{"kv-app-prod.vault.azure.net", "kv-app-prod"},
provider.Source{Kind: provider.KindAzure, Provider: "Azure:bob"})(&n)
if n.Source != "sub-prod" || n.Path[0] != "platform-prod" || n.Account != "Azure:bob" {
t.Errorf("context = %+v", n)
}
}
func TestToggleStarInGoTo(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j", "f") // star kv-shared-prod
d.keys("3", "j", "space", "2") // use smtp-password, unstarred
smtp := kvSharedKey + "/secrets/smtp-password"
ctrlF := tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}
d.keys("R", "/")
d.typeText("smtp")
d.send(ctrlF) // star it from Recent, typing
if !d.m.store.Node(smtp).StarredFor("Azure") || !strings.Contains(d.screen(), "★ ") {
t.Fatalf("ctrl+f didn't star a recent item:\n%s", d.screen())
}
d.send(ctrlF) // and back
if d.m.store.Node(smtp).StarredFor("Azure") {
t.Error("second ctrl+f didn't unstar")
}
if d.m.search == nil || d.m.search.input.Value() != "smtp" || len(d.m.search.shown) == 0 {
t.Fatalf("entry or query gone after toggling:\n%s", d.screen())
}
// Unstarring a favourite keeps it listed until Go to opens again.
d.keys("esc", "esc", "F") // stop typing, close, open on Favourites
v := d.m.search
for v.res < len(v.shown)-1 && v.shown[v.res].star != kvSharedKey {
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
if v.shown[v.res].star != kvSharedKey {
t.Fatalf("selected %q", v.shown[v.res].star)
}
d.keys("f") // browsing: f
if d.m.store.Node(kvSharedKey).StarredFor("Azure") {
t.Error("favourite still starred")
}
if v.shown[v.res].star != kvSharedKey || v.shown[v.res].starred {
t.Error("unstarred favourite didn't stay in place, unstarred")
}
d.keys("esc")
if strings.Contains(d.screen(), "★ kv-shared-prod") {
t.Errorf("Vaults pane still shows the star:\n%s", d.screen())
}
}
func TestStarWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
for _, r := range "api" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
t.Errorf("ctrl+f while filtering didn't star:\n%s", d.screen())
}
}
func TestStarIsPerAccount(t *testing.T) {
t.Parallel()
alice := fake.Azure(0).As("Azure:alice", "Azure · alice")
bob := fake.Azure(0).As("Azure:bob", "Azure · bob")
d := newDriverOpts(t, Options{}, alice, bob)
// Rows: alice header, platform-prod, platform-dev, bob header, ….
d.keys("f") // alice's platform-prod
n := d.m.store.Node("lazysecrets://azure/subscriptions/sub-prod")
if !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") {
t.Errorf("starred by %v, want alice only", n.StarredBy)
}
if c := strings.Count(d.screen(), "★ platform-prod"); c != 1 {
t.Errorf("star shown %d times, want once:\n%s", c, d.screen())
}
// A note is about the thing: it shows under both.
d.keys("n")
d.typeText("shared note")
d.keys("enter")
if n := strings.Count(d.screen(), "shared note"); n != 2 {
t.Errorf("note shown %d times, want 2:\n%s", n, d.screen())
}
d.keys("F")
var labels []string
for _, o := range d.m.search.shown {
labels = append(labels, ansiStrip(o.label))
}
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels)
}
}
+74
View File
@@ -0,0 +1,74 @@
package app
import tea "charm.land/bubbletea/v2"
// At startup, if lazysecrets:// links don't open lazysecrets, a popup
// offers to set that up, once per start until the user says not to ask
// again; that choice is kept in the store.
// HandlerSetup offers to set up the link handler.
type HandlerSetup struct {
Reason string // why links don't open lazysecrets
// Install sets it up, returning what links open in ("kitty").
Install func() (string, error)
}
const (
flagNoHandlerPrompt = "noLinkHandlerPrompt"
setupTitle = "Open links in lazysecrets?"
)
// deferHandlerSetup puts the question back to show later, if a link
// popup is about to replace it.
func (m *Model) deferHandlerSetup() {
if m.prompt != nil && m.prompt.title == setupTitle {
m.askHandler = true
}
}
type handlerInstalledMsg struct {
opens string
err error
}
// askHandlerSetup shows the popup, once no other popup is in the way.
func (m *Model) askHandlerSetup() {
if !m.askHandler || m.prompt != nil || m.search != nil {
return
}
m.askHandler = false
setup := m.handlerSetup
m.prompt = &modal{
title: setupTitle,
lines: []string{
setup.Reason + ".",
m.st.Muted.Render("Set up, clicking a lazysecrets:// link (in Slack, a browser…) opens it here."),
},
options: []option{
{key: "i", label: "Set it up", run: func(*Model) tea.Cmd {
return func() tea.Msg {
opens, err := setup.Install()
return handlerInstalledMsg{opens, err}
}
}},
{key: "s", label: "Not now", tip: "Asks again next time lazysecrets starts.", run: func(*Model) tea.Cmd { return nil }},
{key: "n", label: "Don't ask again", tip: "lazysecrets handler install sets it up later.", run: func(m *Model) tea.Cmd {
if err := m.store.SetFlag(flagNoHandlerPrompt, true); err != nil {
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
}
return nil
}},
},
}
}
func (m *Model) handlerInstalled(msg handlerInstalledMsg) {
switch {
case msg.err != nil:
m.flash = flash{text: "setting up links: " + msg.err.Error(), isErr: true}
case msg.opens != "":
m.flash = done("lazysecrets:// links now open here, or in a new " + msg.opens + " window")
default:
m.flash = done("lazysecrets:// links now open in lazysecrets")
}
}
+71
View File
@@ -0,0 +1,71 @@
package app
import (
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
func setupOpts(mem *store.Store, installs *int) Options {
return Options{Store: mem, HandlerSetup: &HandlerSetup{
Reason: "lazysecrets:// links don't open lazysecrets yet",
Install: func() (string, error) { *installs++; return "kitty", nil },
}}
}
func TestHandlerSetupPopup(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
installs := 0
d := newDriverOpts(t, setupOpts(mem, &installs))
for _, want := range []string{"Open links in lazysecrets?", "links don't open lazysecrets yet", "i Set it up", "s Not now", "n Don't ask again"} {
if !strings.Contains(d.screen(), want) {
t.Fatalf("missing %q:\n%s", want, d.screen())
}
}
d.keys("i")
if installs != 1 || !strings.Contains(d.screen(), "links now open here, or in a new kitty window") {
t.Errorf("set up: %d installs\n%s", installs, d.screen())
}
// Not now: asked again next start.
d = newDriverOpts(t, setupOpts(mem, &installs))
d.keys("s")
if d.m.prompt != nil || mem.Flag(flagNoHandlerPrompt) {
t.Error("not now")
}
d = newDriverOpts(t, setupOpts(mem, &installs))
if d.m.prompt == nil {
t.Fatal("not asked again after not now")
}
// Don't ask again: remembered.
d.keys("n")
if !mem.Flag(flagNoHandlerPrompt) {
t.Error("don't ask again not stored")
}
d = newDriverOpts(t, setupOpts(mem, &installs))
if d.m.prompt != nil {
t.Errorf("asked after don't ask again:\n%s", d.screen())
}
}
// A link given at startup is offered first; the setup question waits.
func TestHandlerSetupWaitsForLink(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
installs := 0
opts := setupOpts(mem, &installs)
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key")
opts.Open = &r
d := newDriverOpts(t, opts)
if !strings.Contains(d.screen(), "Open link") {
t.Fatalf("link not first:\n%s", d.screen())
}
d.keys("x")
if !strings.Contains(d.screen(), "Open links in lazysecrets?") {
t.Errorf("setup question not shown after the link:\n%s", d.screen())
}
}
+314
View File
@@ -0,0 +1,314 @@
package app
import (
"fmt"
"slices"
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindings).
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The
// bubbles defaults are grey on grey.
func newHelp(st styles.Styles) help.Model {
h := help.New()
h.Styles.ShortKey = st.Label
h.Styles.ShortDesc = lipgloss.NewStyle()
h.Styles.ShortSeparator = st.Muted
h.Styles.Ellipsis = st.Muted
h.Styles.FullKey, h.Styles.FullDesc, h.Styles.FullSeparator = h.Styles.ShortKey, h.Styles.ShortDesc, h.Styles.ShortSeparator
return h
}
// contextHelp is the help bar for the focused pane.
type contextHelp struct {
k keys.Map
focus pane
commands bool // custom commands apply here
}
func (c contextHelp) ShortHelp() []key.Binding {
k := c.k
var b []key.Binding
if k.Login.Enabled() {
b = append(b, k.Login)
}
switch c.focus {
case paneSources:
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.SourcesView, k.Filter)
case paneContainers:
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter)
default:
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, k.Filter)
}
if c.commands {
b = append(b, k.Commands)
}
return append(b, k.OpenRef, k.Help, k.Quit)
}
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys
// ("") first, then the rest; from a popup (keys.GoTo, keys.Menu), only
// the keys that work there, and it comes back to the popup when closed.
func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
// Where it was opened from, to come back to.
search, prompt := m.search, m.prompt
m.search = nil
actions := m.keys.Actions()
id := func(a keys.Action) string { return a.Context + "." + a.Name }
here := map[string]bool{}
var local []keys.Action
switch ctx {
case "":
for _, b := range (contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}).ShortHelp() {
for _, a := range actions {
if a.Context == keys.Lists && !here[id(a)] && slices.Equal(a.Binding.Keys(), b.Keys()) && a.Desc == b.Help().Desc {
here[id(a)] = true
local = append(local, a)
}
}
}
default:
for _, a := range actions {
if a.Context == ctx {
here[id(a)] = true
local = append(local, a)
}
}
}
var rest []keys.Action
for _, a := range actions {
if !here[id(a)] && a.Binding.Enabled() && !(a.Context == keys.Lists && a.Name == "help") {
rest = append(rest, a)
}
}
var opts []option
section := func(title string, list []keys.Action) {
opts = append(opts, option{label: title, header: true})
for _, a := range list {
h := a.Binding.Help()
if len(a.Binding.Keys()) == 0 {
continue // unbound
}
msg := keyPress(a.Binding.Keys()[0])
// Go to's typing keys need Go to typing: start it first.
typing := ctx == keys.GoTo && (a.Context == keys.Popup || a.Context == keys.Search)
label := m.st.Label.Render(fmt.Sprintf("%-14s", h.Key)) + fmt.Sprintf("%-26s", h.Desc) + m.st.Muted.Render(a.Group)
opts = append(opts, option{label: label, tip: a.Tip, run: func(m *Model) tea.Cmd {
send := func() tea.Msg { return msg }
if typing && m.search != nil && !m.search.typing {
// Typing starts now; its command only blinks the cursor.
return tea.Batch(m.setTyping(true), send)
}
return send
}})
}
}
title := map[string]string{"": paneNames[m.focus] + " pane", keys.GoTo: "Go to", keys.Menu: "This menu"}[ctx]
section(title, local)
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
}
}
switch ctx {
case "":
section("Global", rest)
case keys.GoTo:
// Only what works here: Go to's keys, browsing and typing.
var typing []keys.Action
for _, a := range actions {
if a.Context == keys.Popup || a.Context == keys.Search {
typing = append(typing, a)
}
}
section("While typing, after "+keys.Name(m.keys.GoFilter), typing)
}
cmd := m.showFilterMenu("Keybindings", opts)
m.prompt.footer = footer(move(m.keys.PopupUp, m.keys.PopupDown), hint(m.keys.PopupSelect, "run"), hint(m.keys.PopupClose, "close"))
if ctx != "" {
m.prompt.onClose = func(m *Model) { m.search, m.prompt = search, prompt }
}
return cmd
}
// versionLabel is the version as shown: "v0.1.0", or the build for
// development builds ("dev-c1a4f90").
func (m Model) versionLabel() string {
if m.version == "" || strings.HasPrefix(m.version, "dev") || strings.HasPrefix(m.version, "v") {
return m.version
}
return "v" + m.version
}
// helpBar draws bindings as the help bar. When they don't fit, entries
// before the last keep (like "? help • q quit") give way, marked "…",
// so those always show.
func (m Model) helpBar(bindings []key.Binding, keep int) string {
var bs []key.Binding
for _, b := range bindings {
if b.Enabled() {
bs = append(bs, b)
}
}
h := m.help
h.SetWidth(0) // measure untruncated
limit := max(0, m.w-1)
if v := m.versionLabel(); v != "" {
limit -= ansi.StringWidth(v) + 2 // a gap, the version, the margin
}
if full := h.ShortHelpView(bs); ansi.StringWidth(full) <= limit {
return full
}
keep = min(keep, len(bs))
head, tail := bs[:len(bs)-keep], bs[len(bs)-keep:]
sep := h.Styles.ShortSeparator.Render(h.ShortSeparator)
more := h.Styles.Ellipsis.Render("…")
for n := len(head); n >= 0; n-- {
line := more + sep + h.ShortHelpView(tail)
if n > 0 {
line = h.ShortHelpView(head[:n]) + sep + line
}
if ansi.StringWidth(line) <= limit || n == 0 {
return ansi.Truncate(line, limit, "…")
}
}
return ""
}
// popupKeys are the open popup's keys for the help bar: Go to's for its
// mode and tab, a menu's, or a text field's.
func (m Model) popupKeys() []key.Binding {
km := m.keys
describe := func(b key.Binding, desc string) key.Binding {
b.SetHelp(b.Help().Key, desc)
return b
}
if v := m.search; v != nil {
inSearch := v.tab == tabSearch
if v.typing {
b := []key.Binding{km.PopupDown, km.PopupUp}
if inSearch {
b = append(b, km.SearchSources, km.SearchResults, km.SearchLoad, km.SearchLoadAll)
}
return append(b, km.PopupStar, km.PopupSelect, describe(km.PopupClose, "stop typing"))
}
b := []key.Binding{km.GoDown, km.GoUp, km.GoPrevTab, km.GoNextTab}
if inSearch {
b = append(b, km.GoSources, km.GoResults, km.GoLoad, km.GoLoadAll)
}
return append(b, km.GoFilter, km.GoStar, km.GoSelect, km.GoClose, km.Help)
}
switch p := m.prompt; {
case p.input != nil:
return []key.Binding{describe(km.PopupSelect, "confirm"), describe(km.PopupClose, "cancel")}
case p.filter != nil:
return []key.Binding{km.PopupDown, km.PopupUp, km.PopupSelect, km.PopupClose}
}
return []key.Binding{km.MenuDown, km.MenuUp, km.MenuSelect, km.MenuClose, km.Help}
}
// A keyHint is a key and what it does, for footers and hints, read from
// the bindings so they show what's bound.
type keyHint struct{ key, desc string }
func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), desc} }
// move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint {
u, d := keys.Name(up), keys.Name(down)
if u == "" || d == "" {
return keyHint{u + d, "move"}
}
return keyHint{u + "/" + d, "move"}
}
// withKey fills a hint's format with the binding's key ("· %s loads
// everything"), or is empty when the action is unbound.
func withKey(b key.Binding, format string) string {
if k := keys.Name(b); k != "" {
return fmt.Sprintf(format, k)
}
return ""
}
// joinKeys is "ctrl+h/ctrl+l" for two bindings, or whichever is bound.
func joinKeys(a, b key.Binding) string {
x, y := keys.Name(a), keys.Name(b)
if x != "" && y != "" {
return x + "/" + y
}
return x + y
}
// footer joins hints as "↑/↓ move · enter go", leaving out unbound keys.
func footer(hints ...keyHint) string {
var parts []string
for _, h := range hints {
if h.key != "" {
parts = append(parts, h.key+" "+h.desc)
}
}
return strings.Join(parts, " · ")
}
// keyPress builds the key press a binding's key name stands for, so the ?
// menu can run it.
func keyPress(name string) tea.KeyPressMsg {
var mod tea.KeyMod
for {
switch {
case strings.HasPrefix(name, "ctrl+") && len(name) > 5:
mod |= tea.ModCtrl
name = name[5:]
continue
case strings.HasPrefix(name, "shift+") && len(name) > 6:
mod |= tea.ModShift
name = name[6:]
continue
}
break
}
named := map[string]rune{
"space": tea.KeySpace, "enter": tea.KeyEnter, "tab": tea.KeyTab, "esc": tea.KeyEscape,
"up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight,
"home": tea.KeyHome, "end": tea.KeyEnd, "backspace": tea.KeyBackspace,
}
if code, ok := named[name]; ok {
k := tea.KeyPressMsg{Code: code, Mod: mod}
if code == tea.KeySpace && mod == 0 {
k.Text = " "
}
return k
}
r := []rune(name)[0]
if mod != 0 {
return tea.KeyPressMsg{Code: r, Mod: mod}
}
return tea.KeyPressMsg{Code: r, Text: name}
}
+198
View File
@@ -0,0 +1,198 @@
package app
import (
"cmp"
"maps"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// The Favourites and Recent scopes of the Go to popup list what's
// remembered in the store, so they work before anything has loaded.
// storeEntries lists starred items (sources first, then vaults, then
// secrets) and recently used ones (newest first), as Go to entries.
// Entries read as paths: "󰠅 platform-prod / kv-app-prod / api-key · dev",
// the account last, and only when there are several.
func (m *Model) storeEntries() (favs, recent []option) {
type entry struct {
depth int // 0 source, 1 vault, 2 secret: sources list first
sort string
opt option
}
nodes := m.store.Nodes()
keys := slices.Sorted(maps.Keys(nodes))
// entries lists jumps to the item through each of accounts; nil
// means through whichever accounts list a source, else the account
// that last read the item.
entries := func(prefix, key string, n store.Node, accounts []string) []entry {
r, err := ref.Parse(key)
if err != nil {
return nil
}
var out []entry
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
account := ""
if p, ok := m.byID[acct]; ok {
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
}
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
run: func(m *Model) tea.Cmd {
m.used(key)
return run(m)
}}})
}
if r.IsSource() {
for _, p := range m.providers {
if p.Kind() != r.Kind || (accounts != nil && !slices.Contains(accounts, p.ID())) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if !sourceMatches(r, src) {
continue
}
row := sourceRowKey(p.ID(), src.ID)
add([]string{src.Name}, p.ID(), 0, func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil
})
}
}
return out
}
if accounts == nil {
accounts = []string{n.Account}
}
depth := 1
if !r.IsContainer() {
depth = 2
}
path := slices.Clone(n.Path)
if len(path) == 0 && !r.IsContainer() {
path = []string{r.ContainerName()} // no context recorded
}
for _, acct := range accounts {
via := n
via.Account = acct
add(append(slices.Clone(path), cmp.Or(n.Name, r.Name, r.ContainerName())), acct, depth,
func(m *Model) tea.Cmd { return m.jumpTo(r, via) })
}
return out
}
var starred []entry
for _, k := range keys {
if n := nodes[k]; len(n.StarredBy) > 0 {
starred = append(starred, entries("fav|", k, n, n.StarredBy)...)
}
}
slices.SortFunc(starred, func(a, b entry) int {
return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort))
})
for _, e := range starred {
favs = append(favs, e.opt)
}
if m.history {
var used []string
for _, k := range keys {
if !nodes[k].LastUsed.IsZero() {
used = append(used, k)
}
}
slices.SortStableFunc(used, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
for _, k := range used[:min(len(used), recentMax)] {
for _, e := range entries("recent|", k, nodes[k], nil) {
recent = append(recent, e.opt)
}
}
}
return favs, recent
}
// recentMax is how many recently used items Recent lists.
const recentMax = 20
// pathLabel draws a jump list entry: the provider's icon and the path in
// its colour, then the account.
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
style := m.st.Kind(kind)
parts := make([]string, len(path))
for i, p := range path {
parts[i] = style.Render(p)
}
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
if account != "" {
label += m.st.Muted.Render(" · " + account)
}
return label
}
// sourceMatches reports whether src is the subscription or workspace r
// links to.
func sourceMatches(r ref.Ref, src provider.Source) bool {
if r.Kind != provider.KindAzure { // by host: workspaces, repos
return strings.EqualFold(strings.TrimSuffix(src.Detail, "/"), r.Host)
}
return src.ID == r.Subscription
}
// accountOf is the account part of a source's provider label ("dev" in
// "Azure · dev"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
return ""
}
return account
}
// jumpTo goes to a remembered vault or secret. With the account that last
// read it signed in, it goes straight there; otherwise it uses the
// accounts that list its subscription, asking if there are several, or
// finds it like a link. Reaching it updates the remembered account.
func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
if n.Source == "" && !r.IsContainer() {
// A secret without context: its vault's record may have it.
parent := r
parent.Type, parent.Name, parent.Version = "", "", ""
if p := m.store.Node(parent.String()); p.Source != "" {
n.Source, n.Account = p.Source, cmp.Or(n.Account, p.Account)
}
}
if _, ok := m.byID[n.Account]; ok && n.Source != "" {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil
}
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if src.ID == n.Source && n.Source != "" {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
return m.openRef(r, actGo)
case 1:
m.startNav(r, actGo, choices[0].sourceRow)
default:
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
}
return nil
}
+189
View File
@@ -0,0 +1,189 @@
package app
import (
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// Layout: the left column stacks Sources, Vaults and Secrets; the detail
// pane fills the rest. The user can move the divider between them, change
// the left panes' heights, and zoom the focused pane.
type zoom int
const (
zoomNormal zoom = iota
zoomHalf // the focused pane gets most of the screen
zoomFull // only the focused pane
zoomCount
)
const (
// Divider between the left column and the detail pane.
splitStep = 4
minLeftW = 20
minDetailW = 30
defaultMinW = 28
defaultMaxW = 48
// Heights of the left panes.
heightStep = 2
minPaneH = 4 // border, title and two rows
tinyPaneH = 3 // a squeezed pane in half zoom: border and one row
)
func (m Model) defaultLeftWidth() int { return max(defaultMinW, min(defaultMaxW, m.w/3)) }
// leftWidth is the default width moved by the user's divider offset,
// kept within bounds that leave both sides usable.
func (m Model) leftWidth() int {
return max(minLeftW, min(m.w-minDetailW, m.defaultLeftWidth()+m.split))
}
// moveSplit moves the divider by delta columns. The offset is stored as
// what actually applied, so after hitting a limit the other direction
// responds immediately.
func (m *Model) moveSplit(delta int) {
m.split += delta
m.split = m.leftWidth() - m.defaultLeftWidth()
}
func defaultHeights(avail int) [3]int {
src, cont := max(minPaneH, avail/4), max(minPaneH, avail/3)
return [3]int{src, cont, avail - src - cont}
}
// leftHeights applies the user's height changes to the default split.
func (m Model) leftHeights(avail int) [3]int {
h := defaultHeights(avail)
for i := range h {
h[i] += m.heights[i]
}
return fitHeights(h, avail, minPaneH)
}
// fitHeights makes the heights sum to avail with none below lo, taking
// from or giving to the tallest panes.
func fitHeights(h [3]int, avail, lo int) [3]int {
for i := range h {
h[i] = max(lo, h[i])
}
for sum(h) > avail {
i := tallest(h)
if h[i] <= lo {
break // the terminal is too short; the layout check catches it
}
h[i]--
}
for sum(h) < avail {
h[tallest(h)]++
}
return h
}
func sum(h [3]int) int { return h[0] + h[1] + h[2] }
func tallest(h [3]int) int {
i := 0
for j := range h {
if h[j] > h[i] {
i = j
}
}
return i
}
// resizeFocused makes the focused left pane taller (delta > 0) or shorter,
// trading rows with the pane below it, or above it for the bottom pane.
// It reports false if the detail pane is focused.
func (m *Model) resizeFocused(delta int) bool {
if m.focus > paneSecrets {
return false
}
avail := m.leftAvail()
h := m.leftHeights(avail)
i := int(m.focus)
j := i + 1
if i == int(paneSecrets) {
j = i - 1
}
delta = max(delta, minPaneH-h[i]) // don't shrink below the minimum
delta = min(delta, h[j]-minPaneH) // or squeeze the neighbour below it
h[i] += delta
h[j] -= delta
base := defaultHeights(avail)
for k := range h {
m.heights[k] = h[k] - base[k]
}
return true
}
// saveLayout remembers the divider and heights for the next run.
func (m *Model) saveLayout() {
if err := m.store.SetLayout(store.Layout{Split: m.split, Heights: m.heights}); err != nil {
m.flash = flash{text: "saving layout: " + err.Error(), isErr: true}
}
}
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
func (m *Model) cycleZoom(delta int) {
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
}
func (m *Model) layout() {
if m.w < minWidth || m.h < minHeight {
return
}
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
avail := m.leftAvail()
leftW, h := m.leftWidth(), m.leftHeights(avail)
switch m.zoom {
case zoomHalf:
if m.focus == paneDetail {
leftW = minLeftW
} else {
leftW = max(m.leftWidth(), m.w/2)
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
h[m.focus] = avail
h = fitHeights(h, avail, tinyPaneH)
}
case zoomFull:
// Only the focused pane is drawn; it gets the whole screen.
switch m.focus {
case paneDetail:
leftW = 0
default:
leftW = m.w
h = [3]int{}
h[m.focus] = avail
}
}
m.sources.SetSize(leftW, h[0])
m.containers.SetSize(leftW, h[1])
m.secrets.SetSize(leftW, h[2])
m.detail.SetSize(m.w-leftW, avail)
}
// active reports whether p is drawn focused: it has focus and the terminal
// does too. Like lazygit, nothing is highlighted while you're in another
// window, so the focus is easy to spot when you come back.
func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
func (m Model) panesView() string {
spin := m.spin.View()
views := [paneCount]func() string{
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
func() string { return m.detail.View(m.st, m.active(paneDetail), spin) },
}
if m.zoom == zoomFull {
return views[m.focus]()
}
left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]())
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
}
+271
View File
@@ -0,0 +1,271 @@
package app
import (
"path/filepath"
"slices"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// paneHeights reads the left panes' heights back from the screen: each
// starts with a "╭─ [n]" line.
func (d *driver) paneHeights() [3]int {
lines := strings.Split(d.screen(), "\n")
var starts []int
for i, l := range lines {
for n := 1; n <= 3; n++ {
if strings.HasPrefix(l, "╭─ ["+string(rune('0'+n))+"]") {
starts = append(starts, i)
}
}
}
if len(starts) != 3 {
d.t.Fatalf("found %d left panes:\n%s", len(starts), d.screen())
}
bottom := len(lines) - 1 - strings.Count(d.m.footerView(), "\n") // first footer line
return [3]int{starts[1] - starts[0], starts[2] - starts[1], bottom - starts[2]}
}
func TestPaneHeights(t *testing.T) {
t.Parallel()
d := newDriver(t) // 30 rows, one footer line: 29 for panes
start := d.paneHeights()
if sum(start) != 29 {
t.Fatalf("heights %v don't fill the column", start)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl}) // Sources taller, from Vaults
got := d.paneHeights()
if got[0] != start[0]+heightStep || got[1] != start[1]-heightStep || got[2] != start[2] {
t.Errorf("ctrl+↓ on Sources: %v → %v", start, got)
}
d.keys("3", "}") // Secrets is the bottom pane: it takes from Vaults above
got2 := d.paneHeights()
if got2[2] != got[2]+heightStep || got2[1] != got[1]-heightStep {
t.Errorf("} on Secrets: %v → %v", got, got2)
}
for range 20 {
d.keys("{") // shrink Secrets as far as it goes
}
if h := d.paneHeights(); h[2] != minPaneH || sum(h) != 29 {
t.Errorf("minimum height: %v", h)
}
d.keys("4", "}")
if !strings.Contains(d.screen(), "focus a left pane") {
t.Error("no hint when resizing from the detail pane")
}
d.keys("=")
if h := d.paneHeights(); h != start {
t.Errorf("= didn't reset heights: %v", h)
}
}
func TestZoom(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "+") // half: Secrets gets most of the column and half the width
h := d.paneHeights()
if h[0] != tinyPaneH || h[1] != tinyPaneH || h[2] != 29-2*tinyPaneH {
t.Errorf("half zoom heights = %v", h)
}
if got := d.leftEdge(); got != 50 {
t.Errorf("half zoom width = %d, want half of 100", got)
}
d.keys("+") // full: only Secrets
screen := d.screen()
if !strings.Contains(screen, "[3] Secrets") || strings.Contains(screen, "[1] Sources") || strings.Contains(screen, "Secret ─") {
t.Errorf("full zoom should show only the Secrets pane:\n%s", screen)
}
if w := len([]rune(strings.Split(screen, "\n")[0])); w != 100 {
t.Errorf("full zoom width = %d", w)
}
d.keys("4") // the zoom follows focus
if !strings.Contains(d.screen(), "╭─ Secret ") || strings.Contains(d.screen(), "[3] Secrets") {
t.Errorf("full zoom on the detail pane:\n%s", d.screen())
}
d.keys("+") // wraps to normal
if d.m.zoom != zoomNormal {
t.Errorf("zoom = %d after three +", d.m.zoom)
}
d.keys("_")
if d.m.zoom != zoomFull {
t.Errorf("_ from normal should go to full, got %d", d.m.zoom)
}
d.keys("=")
if d.m.zoom != zoomNormal {
t.Error("= didn't reset zoom")
}
}
func TestLayoutIsSaved(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "state.json")
load := func() *store.Store {
s, err := store.Load(path)
if err != nil {
t.Fatal(err)
}
return s
}
d := newDriverOpts(t, Options{Store: load()})
d.keys(">", ">", "}")
edge, heights := d.leftEdge(), d.paneHeights()
d2 := newDriverOpts(t, Options{Store: load()})
if d2.leftEdge() != edge || d2.paneHeights() != heights {
t.Errorf("restored %d %v, want %d %v", d2.leftEdge(), d2.paneHeights(), edge, heights)
}
d2.keys("=")
if l := load().Layout(); l.Split != 0 || l.Heights != [3]int{} {
t.Errorf("reset not saved: %+v", l)
}
}
func TestContextHelpBar(t *testing.T) {
t.Parallel()
d := newDriver(t)
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share…") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space reveal") {
t.Errorf("Sources help bar: %q", bar)
}
d.keys("3")
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share…") {
t.Errorf("Secrets help bar: %q", bar)
}
}
func TestKeybindingsMenu(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "?")
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" {
t.Fatalf("? didn't open the menu:\n%s", d.screen())
}
// Sections: the focused pane's keys, then global ones; the cursor
// starts on the first key, and a counter shows where it is.
p := d.m.prompt
if !p.options[0].header || p.options[0].label != "Secrets pane" {
t.Errorf("first section: %+v", p.options[0])
}
if sel := ansi.Strip(p.options[p.cursor].label); !strings.HasPrefix(sel, "space") {
t.Errorf("cursor starts on %q", sel)
}
screen := d.screen()
if !strings.Contains(screen, "Global") || !strings.Contains(screen, "1 of ") {
t.Errorf("no Global section or counter:\n%s", screen)
}
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) // can't go up onto the header
if p.options[p.cursor].header {
t.Error("cursor landed on a header")
}
if !strings.Contains(d.screen(), "Show or hide the selected value") {
t.Errorf("no tip for the selected row:\n%s", d.screen())
}
// The filter matches descriptions and groups too; matches stay under
// their section headers, and empty sections drop out.
for _, r := range "copy" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
var headers []string
for _, o := range d.m.prompt.options {
if o.header {
headers = append(headers, o.label)
}
}
if strings.Join(headers, ",") != "Secrets pane,Global" {
t.Errorf("sections while filtering: %v", headers)
}
if d.m.prompt.options[d.m.prompt.cursor].header {
t.Error("cursor on a header after filtering")
}
var keys []string
for _, o := range d.m.prompt.options {
keys = append(keys, strings.Fields(ansi.Strip(o.label))[0])
}
for _, want := range []string{"y", "Y", "s", "ctrl+y"} {
if !slices.Contains(keys, want) {
t.Errorf("filter copy: %v lacks %s", keys, want)
}
}
// Enter runs the selected binding.
d.keys("esc", "?")
for _, r := range "space" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
if sel := ansi.Strip(d.m.prompt.options[d.m.prompt.cursor].label); !strings.HasPrefix(sel, "space") {
t.Fatalf("filter space: selected %q", sel)
}
d.keys("enter")
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
t.Errorf("running reveal from the menu didn't reveal:\n%s", d.screen())
}
}
func TestKeyPress(t *testing.T) {
t.Parallel()
for name, want := range map[string]string{
"space": "space", "ctrl+y": "ctrl+y", "shift+tab": "shift+tab", "S": "S", "`": "`", "ctrl+left": "ctrl+left",
} {
if got := keyPress(name).String(); got != want {
t.Errorf("keyPress(%q).String() = %q", name, got)
}
}
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return lines[len(lines)-1]
}
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel()
d := newDriver(t)
green := "\x1b[32m╭" // a focused border; "current" versions are green too
if !strings.Contains(d.m.View().Content, green) {
t.Fatal("focused pane not highlighted")
}
d.send(tea.BlurMsg{})
if strings.Contains(d.m.View().Content, green) {
t.Error("still highlighted while the terminal is blurred")
}
d.send(tea.FocusMsg{})
if !strings.Contains(d.m.View().Content, green) {
t.Error("highlight not back after focus")
}
// Popups too.
d.keys("?")
d.send(tea.BlurMsg{})
if strings.Contains(d.m.modalView(), "\x1b[32m") || strings.Contains(d.m.modalView(), "\x1b[7m") {
t.Error("popup still highlighted while the terminal is blurred")
}
d.send(tea.FocusMsg{})
if !strings.Contains(d.m.modalView(), "\x1b[32m╭") {
t.Error("popup highlight not back after focus")
}
}
func TestVersionBottomRight(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Version: "0.1.0"})
bar := lastLine(d.screen())
if !strings.HasSuffix(bar, "v0.1.0 ") || ansi.StringWidth(bar) != 100 {
t.Errorf("help line %q (%d wide)", bar, ansi.StringWidth(bar))
}
if !strings.Contains(bar, "? help • q quit") {
t.Errorf("help squeezed out by the version: %q", bar)
}
}
+887
View File
@@ -0,0 +1,887 @@
package app
import (
"cmp"
"context"
"errors"
"fmt"
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// Opening references: a reference (lazysecrets:// link, Key Vault URL, …)
// is first resolved to a source, then the panes are stepped through to it
// as each list loads. Copy and reveal actions ask before reading the value.
// linksRowID is the Sources row listing vaults opened from links that no
// account could locate (e.g. no read access to the vault resource).
const linksRowID = "links"
type navStage int
const (
navSource navStage = iota
navContainer
navObject
navVersion
navAction
)
// nav is an open in progress.
type nav struct {
ref ref.Ref
act linkAction
// sourceOnly stops at the source (jumping to a starred subscription).
sourceOnly bool
sourceRow string // Sources row to select
stage navStage
}
type resolvedMsg struct {
ref ref.Ref
act linkAction
sourceRow string
others []string // other accounts' rows that also found the vault
choices []choice // several candidates: ask
linked *provider.Container
err error
}
type choice struct {
label string
sourceRow string
}
// linkAction is what to do with an object once it's opened. Links don't
// carry it: whoever opens one chooses.
type linkAction int
const (
actGo linkAction = iota // just go to it
actCopy // and copy its value
actShow // and show its value
)
// offerRef asks what to do with a reference: for an object, go to it, or
// go to it and copy or show its value; for a subscription, vault or scope,
// just open it.
// With ask false, vault and scope links open straight away.
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
if r.IsContainer() || r.IsSource() {
if !ask {
return m.openRef(r, actGo)
}
m.showMenu(title, nil, []option{
{key: "o", label: "Open it", run: func(m *Model) tea.Cmd { return m.openRef(r, actGo) }},
})
m.prompt.about = &r
return nil
}
do := func(a linkAction) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.openRef(r, a) }
}
opts := []option{
{key: "o", label: "Go to it", run: do(actGo)},
{key: "c", label: "Go to it and copy its value", run: do(actCopy)},
{key: "r", label: "Go to it and show its value", run: do(actShow)},
}
if r.Kind == provider.KindAzure || r.Host != "" { // {{secrets/s/k}} names no workspace
opts = append(opts, option{key: "y", label: "Copy its value, staying here", run: func(m *Model) tea.Cmd { return m.copyFromRef(r) }})
}
m.showMenu(title, nil, opts)
m.prompt.about = &r
return nil
}
// copyFromRef copies the value a link points at without going there. The
// link says enough to read it: the vault and name (Azure, tried with each
// account, the one that last read it first) or the workspace, scope and
// key (Databricks).
func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
type try struct {
p provider.Provider
o provider.Object
}
var tries []try
key := r
key.Version = ""
node := m.store.Node(key.String())
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
if len(node.Path) > 0 {
src.Name = node.Path[0]
}
if r.Kind != provider.KindAzure { // by host: Databricks workspaces, repos
o.Type = provider.TypeSecret
src.ID = ""
for _, s := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, s) {
src = s
}
}
if src.ID == "" {
continue
}
o.Container = provider.Container{Source: src, Name: r.Scope}
} else {
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
}
t := try{p, o}
if p.ID() == node.Account {
tries = append([]try{t}, tries...)
} else {
tries = append(tries, t)
}
}
if len(tries) == 0 {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil
}
ctx, req := m.begin(slotCopy)
m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg {
var errs []error
for _, t := range tries {
v, err := t.p.Value(ctx, t.o)
if err == nil {
msg := copyMsg{req: req, name: r.Name, value: v}
if t.o.Container.Source.ID != "" {
msg.obj = t.o // where it is is known: count the use
}
return msg
}
if ctx.Err() != nil {
return copyMsg{req: req, name: r.Name, err: err}
}
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
}
if len(errs) == 1 {
return copyMsg{req: req, name: r.Name, err: errors.Unwrap(errs[0])} // one account: no need to name it
}
return copyMsg{req: req, name: r.Name, err: errors.Join(errs...)}
}
}
// openRef starts opening a reference, doing act once there.
func (m *Model) openRef(r ref.Ref, act linkAction) tea.Cmd {
m.nav = nil
if r.IsSource() {
m.openSource(r)
return nil
}
m.flash = flash{text: "opening " + describeRef(r) + "…"}
cands := m.providers
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), defaultResolveTimeout)
defer cancel()
msg := resolve(ctx, cands, r)
msg.act = act
return msg
}
}
const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) {
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
case 1:
m.nav = &nav{ref: r, sourceRow: choices[0].sourceRow, sourceOnly: true}
default:
var opts []option
for i, c := range choices {
row := c.sourceRow
opts = append(opts, option{key: fmt.Sprint(i + 1), label: c.label, run: func(m *Model) tea.Cmd {
m.nav = &nav{ref: r, sourceRow: row, sourceOnly: true}
return nil
}})
}
m.showMenu("Several accounts can see "+describeRef(r), nil, opts)
}
}
// refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The
// subscription or workspace isn't in the link: it comes from what's
// remembered about the item, or from lists already loaded.
func (m Model) refLabel(r ref.Ref) string {
var path []string
if src := m.refSource(r); src != "" {
path = append(path, src)
}
switch {
case r.IsSource():
if len(path) == 0 {
path = append(path, cmp.Or(r.Subscription, r.Host))
}
case r.IsContainer():
path = append(path, r.ContainerName())
default:
path = append(path, r.ContainerName(), r.Name)
}
label := m.pathLabel(r.Kind, path, "")
var extra []string
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
if r.IsContainer() {
extra = append(extra, t+" tab")
} else {
extra = append(extra, strings.TrimSuffix(t, "s")) // "key", "certificate"
}
}
if r.Version != "" {
extra = append(extra, "version "+r.Version)
}
if len(extra) > 0 {
label += m.st.Muted.Render(" · " + strings.Join(extra, " · "))
}
return label
}
// refSource names the subscription or workspace r is in, if known.
func (m Model) refSource(r ref.Ref) string {
key := r
key.Version = ""
if n := m.store.Node(key.String()); len(n.Path) > 0 {
return n.Path[0]
}
for _, res := range m.sourceResults {
for _, src := range res.sources {
if (r.Kind != provider.KindAzure || r.IsSource()) && src.Kind == r.Kind && sourceMatches(r, src) {
return src.Name
}
}
}
if r.Kind == provider.KindAzure && !r.IsSource() {
for _, e := range m.cache.containers {
for _, c := range e.value {
if matchesContainer(c, r) {
return c.Source.Name
}
}
}
}
return ""
}
func describeRef(r ref.Ref) string {
if r.IsSource() {
switch r.Kind {
case provider.KindDatabricks:
return "workspace " + r.Host
case provider.KindRepo:
return "repo " + r.Host
}
return "subscription " + r.Subscription
}
if r.IsContainer() {
return r.ContainerName()
}
return r.Name + " in " + r.ContainerName()
}
// resolve finds the source holding the reference's container.
func resolve(ctx context.Context, providers []provider.Provider, r ref.Ref) resolvedMsg {
msg := resolvedMsg{ref: r}
var errs []error
switch {
case r.Kind == provider.KindDatabricks && r.Host == "":
// {{secrets/scope/key}}: search every workspace for the scope.
for _, p := range providers {
if p.Kind() != provider.KindDatabricks {
continue
}
srcs, err := p.Sources(ctx)
if err != nil {
errs = append(errs, err)
continue
}
for _, src := range srcs {
cs, err := p.Containers(ctx, src)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", src.Name, err))
continue
}
for _, c := range cs {
if c.Name == r.Scope {
msg.choices = append(msg.choices, choice{label: src.Name, sourceRow: sourceRowKey(p.ID(), src.ID)})
}
}
}
}
switch len(msg.choices) {
case 0:
msg.err = errors.Join(append([]error{fmt.Errorf("scope %q isn't in any workspace", r.Scope)}, errs...)...)
case 1:
msg.sourceRow, msg.choices = msg.choices[0].sourceRow, nil
}
return msg
}
for _, p := range providers {
loc, ok := p.(provider.Locator)
if !ok || p.Kind() != r.Kind {
continue
}
id, found, err := loc.Locate(ctx, r.Host)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err))
continue
}
if found {
// Several accounts may see it, with different access: the
// user picks.
row := sourceRowKey(p.ID(), id)
if msg.sourceRow == "" {
msg.sourceRow = row
} else {
msg.others = append(msg.others, row)
}
}
}
if msg.sourceRow != "" {
return msg
}
if r.Kind == provider.KindAzure {
// No account can find the vault resource, but one may still read
// it: open it under "Opened links", where each account is tried.
msg.linked = &provider.Container{
Source: provider.Source{Kind: provider.KindAzure, Name: r.Host},
ID: r.VaultURL(), Name: r.ContainerName(), Detail: r.VaultURL(),
Types: []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate},
}
return msg
}
missing := fmt.Errorf("no Databricks profile for workspace %s", r.Host)
if r.Kind == provider.KindRepo {
missing = fmt.Errorf("repo %s isn't here: start lazysecrets in it, or list it under providers.repos.paths", r.Host)
}
msg.err = errors.Join(append([]error{missing}, errs...)...)
return msg
}
func (m *Model) handleResolved(msg resolvedMsg) {
switch {
case msg.err != nil:
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
case len(msg.choices) > 1:
for i := range msg.choices {
msg.choices[i].label = "Open in " + msg.choices[i].label
}
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
case len(msg.others) > 0:
var choices []choice
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
case msg.linked != nil:
m.addLinked(*msg.linked)
m.startNav(msg.ref, msg.act, linksRowID)
default:
m.startNav(msg.ref, msg.act, msg.sourceRow)
}
}
// chooseSource asks where to open r.
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
var opts []option
for i, c := range choices {
key := ""
if i < 9 {
key = fmt.Sprint(i + 1)
}
row := c.sourceRow
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
m.startNav(r, act, row)
return nil
}})
}
m.showMenu(title, nil, opts)
}
// sourceLabel names a Sources row as "Azure · admin / app-dev".
func (m Model) sourceLabel(row string) string {
providerID, sourceID := splitSourceRow(row)
label := providerID
if p, ok := m.byID[providerID]; ok {
label = p.Label()
}
for _, s := range m.sourceResults[providerID].sources {
if s.ID == sourceID {
return label + " / " + s.Name
}
}
return label
}
func (m *Model) startNav(r ref.Ref, act linkAction, sourceRow string) {
m.nav = &nav{ref: r, act: act, sourceRow: sourceRow}
m.versionsOf = nil
m.flash = flash{}
}
// addLinked lists a vault under "Opened links" for this session.
func (m *Model) addLinked(c provider.Container) {
for _, l := range m.linked {
if l.ID == c.ID {
return
}
}
m.linked = append(m.linked, c)
m.rebuildSources()
}
func (m *Model) navFail(format string, a ...any) {
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
m.nav = nil
}
// advanceNav moves an open along as far as the loaded lists allow. It
// reports whether it got further, so the caller can sync and try again.
func (m *Model) advanceNav() bool {
n := m.nav
if n == nil {
return false
}
r := n.ref
switch n.stage {
case navSource:
if !m.sources.Select(n.sourceRow) {
group, _ := splitSourceRow(n.sourceRow)
if m.collapsed[group] {
m.collapsed[group] = false
m.rebuildSources()
return true
}
if m.sourcesPending > 0 {
return false // still loading
}
m.navFail("%s isn't in the Sources list (hidden in the config?)", describeRef(r))
return false
}
m.sourcesTouched = true
m.focus = paneContainers
if n.sourceOnly {
m.nav = nil
return true
}
n.stage = navContainer
return true
case navContainer:
if m.selSource != n.sourceRow || m.containers.Loading {
return false
}
if !m.containers.SelectWhere(func(c provider.Container) bool { return matchesContainer(c, r) }) {
m.navFail("%s not found", r.ContainerName())
return false
}
if r.Type != "" {
m.wantType = r.Type
}
if !r.IsContainer() {
m.focus = paneSecrets
}
n.stage = navObject
return true
case navObject:
c, _ := m.containers.Selected()
if (r.Type != "" && m.objType != r.Type) || m.secrets.Loading ||
!strings.HasPrefix(m.selContainer, containerKey(c)+"|") {
return false
}
if m.secrets.Err != nil {
m.nav = nil // the pane shows the error
return false
}
// Reached it, and its account can read it: remember that.
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
if r.IsContainer() {
m.nav = nil
return true
}
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Name == r.Name }) {
m.navFail("%s not found in %s", r.Name, r.ContainerName())
return false
}
if o, ok := m.secrets.Selected(); ok {
m.refreshContext(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
}
if r.Version != "" {
o, _ := m.secrets.Selected()
m.versionsOf = &o
n.stage = navVersion
return true
}
n.stage = navAction
return true
case navVersion:
if m.versionsOf == nil || m.secrets.Loading || m.secrets.Err != nil {
if m.secrets.Err != nil {
m.nav = nil
}
return false
}
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Version == r.Version }) {
m.flash = flash{text: "version " + r.Version + " not found; showing all versions", isErr: true}
}
n.stage = navAction
return true
case navAction:
// The user chose this when opening the link; no need to ask again.
m.nav = nil
switch n.act {
case actCopy:
m.pendingCmd = m.copyValue()
case actShow:
if m.detail.State() != panes.ValueShown {
m.pendingCmd = m.toggleReveal()
}
}
return true
}
return false
}
// matchesContainer compares by vault URL (Azure), or scope or file name.
func matchesContainer(c provider.Container, r ref.Ref) bool {
if r.Kind != provider.KindAzure {
return c.Name == r.Scope
}
return strings.EqualFold(strings.TrimSuffix(c.Detail, "/")+"/", r.VaultURL())
}
// share copies a link to the focused item. With menu, it offers the
// other things worth sharing about it.
func (m *Model) share(menu bool) tea.Cmd {
switch m.focus {
case paneSources:
s, ok := m.selectedSource()
if !ok {
m.flash = warning("select a subscription, workspace, vault or secret to share")
return nil
}
if !menu {
return m.shareSource(s, "o")
}
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareSource(s, k) }
}
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
if s.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
}
m.showMenu("Share "+s.Name, nil, opts)
case paneContainers:
c, ok := m.containers.Selected()
if !ok {
return nil
}
if !menu {
return m.shareContainer(c, "o")
}
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
}
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
}
if c.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Vault URL", run: share("u")})
}
if c.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
}
m.showMenu("Share "+c.Name, nil, opts)
default:
o, ok := m.detail.Secret()
if !ok {
return nil
}
if !menu {
return m.shareObject(o, "o")
}
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
}
opts := []option{
option{key: "o", label: "Lazysecrets link", run: share("o")},
}
if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
}
if o.Reference != "" {
opts = append(opts, option{key: "y", label: "Config reference", run: share("y")})
}
if o.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
}
m.showMenu("Share "+o.Name, nil, opts)
}
return nil
}
// webLabel names the web page o (open in browser) opens.
func webLabel(kind provider.Kind) string {
switch kind {
case provider.KindDatabricks:
return "Web page (Databricks workspace)"
case provider.KindRepo:
return "Web page (repository)"
}
return "Web page (Azure portal)"
}
// shareSource copies what the share menu's key k stands for, for a
// subscription or workspace.
func (m *Model) shareSource(s provider.Source, k string) tea.Cmd {
m.usedSource(s)
if k == "b" {
if s.URL == "" {
return nil
}
return m.copyText(s.Name+" web page", s.URL, false)
}
r, err := ref.ForSource(s)
if err != nil {
m.flash = flash{text: "share: " + err.Error(), isErr: true}
return nil
}
return m.copyText("link to "+s.Name, r.String(), false)
}
// shareContainer copies what the share menu's key k stands for, for a
// vault or scope.
func (m *Model) shareContainer(c provider.Container, k string) tea.Cmd {
m.usedContainer(c)
tab := provider.TypeSecret
switch k {
case "b":
if c.URL == "" {
return nil
}
return m.copyText(c.Name+" web page", c.URL, false)
case "t":
tab = m.objType
case "o", "u":
default:
return nil
}
r, err := ref.ForContainer(c, tab)
if err != nil {
m.flash = flash{text: "share: " + err.Error(), isErr: true}
return nil
}
if k == "u" {
if u := r.IdentifierURL(); u != "" {
return m.copyText(c.Name+" URL", u, false)
}
return nil
}
return m.copyText("link to "+c.Name, r.String(), false)
}
// shareObject copies what the share menu's key k stands for.
func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
m.usedObject(o)
if k == "b" {
if o.URL == "" {
return nil
}
return m.copyText(o.Name+" web page", o.URL, false)
}
if k == "y" {
if o.Reference == "" {
return nil
}
return m.copyText(o.Name+" reference", o.Reference, false)
}
version := ""
if m.versionsOf != nil {
version = o.Version // shared from the versions view: that version
}
r, err := ref.ForObject(o, version)
if err != nil {
m.flash = flash{text: "share: " + err.Error(), isErr: true}
return nil
}
switch k {
case "u":
if u := r.IdentifierURL(); u != "" {
return m.copyText(o.Name+" URL", u, false)
}
return nil
}
return m.copyText("link to "+o.Name, r.String(), false)
}
func (m *Model) startOpenPrompt() tea.Cmd {
in := textinput.New()
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{
title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
submit: func(m *Model, text string) tea.Cmd { return m.openText(text) },
}
// Pre-fill from the clipboard, read now: it may hold any reference,
// including ones not offered in the footer (our own, dismissed ones).
clip := m.clip
readClip := func() tea.Msg {
text, err := clip.Read()
if err != nil {
return nil
}
text = strings.TrimSpace(text)
if _, err := ref.Parse(text); err != nil || strings.ContainsAny(text, "\n") {
return nil // not a reference: don't keep it
}
return openPromptFillMsg(text)
}
return tea.Batch(m.prompt.input.Focus(), readClip)
}
type openPromptFillMsg string
// fillOpenPrompt puts a reference from the clipboard in the open prompt,
// unless the user has started typing.
func (m *Model) fillOpenPrompt(text string) {
if p := m.prompt; p != nil && p.input != nil && p.input.Value() == "" {
p.input.SetValue(text)
p.input.CursorEnd()
}
}
// openText parses a pasted or typed reference and asks what to do with it.
func (m *Model) openText(s string) tea.Cmd {
r, err := ref.Parse(s)
if err != nil {
m.flash = flash{text: "open: " + err.Error(), isErr: true}
return nil
}
return m.offerRef("Open link", r, false)
}
// handlePaste opens a pasted reference, unless a text input has focus.
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
if p := m.prompt; p != nil && p.input != nil {
in, cmd := p.input.Update(msg)
p.input = &in
return cmd
}
if l := m.focusedFilter(); l != nil && l.Filtering() {
return l.UpdateFilter(msg)
}
if m.prompt != nil {
return nil
}
return m.openText(msg.Content)
}
type clipboardTextMsg string
// clipCheckEvery throttles the clipboard check on key presses, which
// covers terminals that don't report focus.
const clipCheckEvery = 3 * time.Second
// maybeCheckClipboard checks the clipboard if it hasn't been for a while.
func (m *Model) maybeCheckClipboard() tea.Cmd {
if now := m.now(); now.Sub(m.clipChecked) >= clipCheckEvery {
m.clipChecked = now
return m.checkClipboard()
}
return nil
}
// checkClipboard reads the clipboard in the background to look for a
// lazysecrets:// link. Only such links are kept; anything else (it may well
// be a password) is dropped as soon as it has been looked at.
func (m Model) checkClipboard() tea.Cmd {
clip := m.clip
return func() tea.Msg {
text, err := clip.Read()
if err != nil {
return nil
}
text = strings.TrimSpace(text)
if !isLink(text) {
return clipboardTextMsg("")
}
return clipboardTextMsg(text)
}
}
// isLink reports whether s looks like a lazysecrets:// link, valid or not.
func isLink(s string) bool {
return strings.HasPrefix(strings.ToLower(s), ref.Scheme+"://")
}
// offerClipboardLink pops up a link found on the clipboard, once: whatever
// the user picks, that link isn't offered again. It isn't offered while
// another popup is open, or if it points at what's selected already (e.g.
// just shared with s). A lazysecrets:// link that doesn't parse is
// reported, so a typo doesn't fail silently.
func (m *Model) offerClipboardLink(text string) {
if text == "" || text == m.dismissedLink || m.prompt != nil || m.search != nil {
return
}
r, err := ref.Parse(text)
if err != nil {
m.dismissedLink = text
m.showMenu("Link on clipboard can't be read", []string{m.st.Error.Render(err.Error())}, nil)
return
}
if m.isSelected(r) {
return
}
m.dismissedLink = text
m.offerRef("Link on clipboard", r, true)
}
// isSelected reports whether r points at the selected container, or the
// selected object in it.
func (m Model) isSelected(r ref.Ref) bool {
c, ok := m.containers.Selected()
if !ok || c.Source.Kind != r.Kind || !matchesContainer(c, r) {
return false
}
if r.IsContainer() {
return true
}
o, ok := m.detail.Secret()
return ok && o.Name == r.Name && (r.Type == "" || o.Type == r.Type) &&
(r.Version == "" || o.Version == r.Version)
}
+600
View File
@@ -0,0 +1,600 @@
package app
import (
"context"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
const kv = "kv-app-prod.vault.azure.net"
// open pastes a reference and, for an object, chooses to go to it.
func (d *driver) open(link string) {
d.t.Helper()
d.openWith(link, "o")
}
// openWith pastes a reference and answers the "Open link" menu with k.
func (d *driver) openWith(link, k string) {
d.t.Helper()
d.send(tea.PasteMsg{Content: link})
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
d.keys(k)
}
}
func TestOpenSecretLink(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "j") // start elsewhere: platform-dev
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Fatalf("opened %s/%s/%s", src, c, s)
}
if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden {
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
}
}
func TestOpenAcceptsOtherFormats(t *testing.T) {
t.Parallel()
for _, in := range []string{
"https://" + kv + "/secrets/api-key",
"@Microsoft.KeyVault(SecretUri=https://" + kv + "/secrets/api-key)",
"@Microsoft.KeyVault(VaultName=kv-app-prod;SecretName=api-key)",
} {
d := newDriver(t)
d.open(in)
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("%s: selected %q", in, s)
}
}
}
func TestOpenVaultLinkOnTab(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "j")
d.open("lazysecrets://azure/" + kv + "/certificates")
if _, c, s := d.selected(); c != "kv-app-prod" || d.m.objType != provider.TypeCertificate || s != "app-tls" {
t.Errorf("vault link: %q tab %s selected %q", c, d.m.objType, s)
}
if d.m.focus != paneContainers {
t.Errorf("focus = %d, want the vaults pane", d.m.focus)
}
}
func TestOpenVersionLink(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.open("lazysecrets://azure/" + kv + "/secrets/db-password/91d3e0")
if d.m.versionsOf == nil {
t.Fatal("versions view not opened")
}
if o, _ := d.m.secrets.Selected(); o.Version != "91d3e0" {
t.Errorf("selected version %q", o.Version)
}
}
func TestOpeningAsksWhatToDo(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/db-password"})
screen := d.screen()
for _, want := range []string{"Open link", "platform-prod / kv-app-prod / db-password", "o Go to it",
"c Go to it and copy its value", "r Go to it and show its value", "x Dismiss"} {
if !strings.Contains(screen, want) {
t.Fatalf("missing %q:\n%s", want, screen)
}
}
if d.clip().content != "" {
t.Fatal("did something before being asked")
}
d.keys("c")
if d.clip().content != "correct-horse-battery-staple" || strings.Contains(d.screen(), "correct-horse") {
t.Errorf("copy: clipboard %q, revealed %v", d.clip().content, strings.Contains(d.screen(), "correct-horse"))
}
d2 := newDriver(t)
d2.keys("1", "j")
d2.openWith("lazysecrets://azure/"+kv+"/secrets/api-key", "r")
if _, _, s := d2.selected(); s != "api-key" || !strings.Contains(d2.screen(), "sk_live_51HxDEMO") {
t.Errorf("show: selected %q\n%s", s, d2.screen())
}
// Old links carrying ?action= still open, and still ask.
d3 := newDriver(t)
d3.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/db-password?action=copy"})
if d3.m.prompt == nil || d3.clip().content != "" {
t.Error("an old ?action=copy link acted without asking")
}
}
func TestOpenDatabricksLinks(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.open("lazysecrets://databricks/adb-2222222222222222.2.azuredatabricks.net/app/storage-key")
if src, c, s := d.selected(); src != "dev" || c != "app" || s != "storage-key" {
t.Errorf("databricks link: %s/%s/%s", src, c, s)
}
// A bare {{secrets/…}} reference: the app scope is in both workspaces.
d.open("{{secrets/app/storage-key}}")
screen := d.screen()
if d.m.prompt == nil || !strings.Contains(screen, "1 Open in prod") || !strings.Contains(screen, "2 Open in dev") ||
!strings.Contains(screen, "x Dismiss") {
t.Fatalf("no choice:\n%s", screen)
}
d.keys("1")
if src, _, s := d.selected(); src != "prod" || s != "storage-key" {
t.Errorf("after choosing prod: %s/%s", src, s)
}
}
// unlocatable hides Locate, like an account that can read a vault's
// secrets but not see the vault resource.
type unlocatable struct{ provider.Provider }
func TestOpenUnlocatableVaultUsesOpenedLinks(t *testing.T) {
t.Parallel()
d := newDriver(t, unlocatable{fake.Azure(0)}, fake.Databricks(0))
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
if !strings.Contains(d.screen(), "↗ Opened links (1)") {
t.Fatalf("no Opened links row:\n%s", d.screen())
}
if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" {
t.Errorf("opened %s/%s", c, s)
}
if c, _ := d.m.containers.Selected(); c.Source.Provider == "" {
t.Error("the account that could read it wasn't recorded")
}
}
func TestOpenErrors(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.open("hello")
if !strings.Contains(d.screen(), "open: not a lazysecrets link") {
t.Errorf("bad paste:\n%s", d.screen())
}
d.open("lazysecrets://azure/" + kv + "/secrets/nope")
if !strings.Contains(d.screen(), "open: nope not found in kv-app-prod") {
t.Errorf("missing secret:\n%s", d.screen())
}
d.open("lazysecrets://databricks/adb-9.9.azuredatabricks.net/app")
if !strings.Contains(d.screen(), "no Databricks profile for workspace adb-9.9") {
t.Errorf("unknown workspace:\n%s", d.screen())
}
}
func TestPasteWhileFilteringFilters(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.send(tea.PasteMsg{Content: "api"})
if d.m.secrets.Len() != 1 || d.m.nav != nil {
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
}
}
func TestOpenPromptAndStartupOpen(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
d.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/api-key"})
d.keys("enter", "o")
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("ctrl+o: selected %q", s)
}
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
d2 := newDriverOpts(t, Options{Open: &r})
if d2.m.prompt == nil || d2.m.prompt.title != "Open link" {
t.Fatal("open at startup didn't ask what to do")
}
d2.keys("o")
if _, _, s := d2.selected(); s != "api-key" {
t.Errorf("open at startup: selected %q", s)
}
}
func TestShareLinks(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "s") // s: the plain link, straight away
if want := "lazysecrets://azure/" + kv + "/secrets/db-password"; d.clip().content != want {
t.Errorf("s = %q", d.clip().content)
}
if strings.Contains(d.screen(), "copies its value") {
t.Error("share menu still offers action links")
}
for k, want := range map[string]string{
"o": "lazysecrets://azure/" + kv + "/secrets/db-password",
"u": "https://" + kv + "/secrets/db-password",
"y": "@Microsoft.KeyVault(SecretUri=https://" + kv + "/secrets/db-password)",
} {
d.keys("S", k)
if d.clip().content != want {
t.Errorf("S %s = %q, want %q", k, d.clip().content, want)
}
}
d.keys("2", "S", "u") // vault menu: plain vault URL
if want := "https://" + kv + "/"; d.clip().content != want {
t.Errorf("vault URL = %q", d.clip().content)
}
d.keys("]", "S", "t") // link that opens on the Keys tab
if want := "lazysecrets://azure/" + kv + "/keys"; d.clip().content != want {
t.Errorf("tab link = %q", d.clip().content)
}
d.keys("[", "S", "b")
if !strings.HasPrefix(d.clip().content, "https://portal.azure.com/") {
t.Errorf("portal link = %q", d.clip().content)
}
d.keys("2", "s")
if want := "lazysecrets://azure/" + kv; d.clip().content != want {
t.Errorf("vault link = %q", d.clip().content)
}
d.keys("3", "v", "j", "s") // from the versions view: that version
if want := "lazysecrets://azure/" + kv + "/secrets/db-password/91d3e0"; d.clip().content != want {
t.Errorf("version link = %q", d.clip().content)
}
d.keys("esc", "1", "j", "j", "j", "3", "S")
if strings.Contains(d.screen(), "Key Vault URL") {
t.Error("Databricks offered a Key Vault URL")
}
d.keys("o")
if want := "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key"; d.clip().content != want {
t.Errorf("databricks link = %q", d.clip().content)
}
// Every shared link opens what was shared.
d2 := newDriver(t)
d2.open(d.clip().content)
if src, c, s := d2.selected(); src != "prod" || c != "app" || s != "storage-key" {
t.Errorf("round trip: %s/%s/%s", src, c, s)
}
}
// blind sees vaults (Locate) but may not read them, like an account with
// Reader on the subscription but no Key Vault data-plane role.
type blind struct{ *fake.Provider }
func (blind) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
return nil, fake.ErrForbidden
}
func TestOpenAsksWhichAccount(t *testing.T) {
t.Parallel()
// IDs like the real ones: they contain a "/".
alice := blind{fake.Azure(0).As("Azure:alice@example.com/t1", "Azure · alice")}
bob := fake.Azure(0).As("Azure:bob@example.com/t1", "Azure · bob")
d := newDriver(t, alice, bob)
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
screen := d.screen()
for _, want := range []string{"Several accounts can see kv-app-prod",
"1 Open with Azure · alice / platform-prod", "2 Open with Azure · bob / platform-prod", "x Dismiss"} {
if !strings.Contains(screen, want) {
t.Fatalf("missing %q:\n%s", want, screen)
}
}
d.keys("2")
c, _ := d.m.containers.Selected()
if _, _, s := d.selected(); s != "api-key" || c.Source.Provider != "Azure:bob@example.com/t1" {
t.Errorf("opened %q via %q, want api-key via bob", s, c.Source.Provider)
}
// j/k and enter work too; x dismisses.
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
d.keys("x")
if d.m.prompt != nil || d.m.nav != nil {
t.Error("x didn't dismiss")
}
d.open("lazysecrets://azure/" + kv + "/secrets/legacy-token")
d.keys("j", "enter")
if c, _ := d.m.containers.Selected(); c.Source.Provider != "Azure:bob@example.com/t1" {
t.Errorf("j enter picked %q", c.Source.Provider)
}
// Opening into a folded account unfolds it.
d.keys("1", "g") // alice's header
d.keys("enter") // fold it
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
d.keys("1")
if c, _ := d.m.containers.Selected(); c.Source.Provider != "Azure:alice@example.com/t1" {
t.Errorf("folded account not opened: %q", c.Source.Provider)
}
}
// offered reports whether a "Link on clipboard" popup is showing.
func (d *driver) offered() bool {
return d.m.prompt != nil && strings.HasPrefix(d.m.prompt.title, "Link on clipboard")
}
func TestLinkOnClipboardPopsUp(t *testing.T) {
t.Parallel()
link := "lazysecrets://azure/" + kv + "/secrets/api-key"
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: link}})
screen := d.screen()
for _, want := range []string{"Link on clipboard", "platform-prod / kv-app-prod / api-key", "o Go to it", "c Go to it and copy its value", "x Dismiss"} {
if !strings.Contains(screen, want) {
t.Fatalf("popup missing %q:\n%s", want, screen)
}
}
d.keys("o")
if _, _, s := d.selected(); s != "api-key" || d.offered() {
t.Errorf("o opened %q, popup still shown: %v", s, d.offered())
}
d.send(tea.FocusMsg{}) // same link: offered once only
if d.offered() {
t.Error("offered again after opening")
}
}
func TestClipboardLinkOfferedOnce(t *testing.T) {
t.Parallel()
d := newDriver(t)
if d.offered() {
t.Fatal("empty clipboard offered a link")
}
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" // copied in another app
d.send(tea.FocusMsg{})
if !d.offered() {
t.Fatal("not offered on focus")
}
d.keys("esc")
d.clip().content = "something else"
d.send(tea.FocusMsg{})
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" // copied again later
d.send(tea.FocusMsg{})
if d.offered() {
t.Error("link offered a second time")
}
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/legacy-token" // a different one
d.send(tea.FocusMsg{})
if !d.offered() {
t.Error("new link not offered")
}
}
func TestClipboardIgnoresOtherText(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: "hunter2"}})
if d.offered() || d.m.dismissedLink != "" {
t.Error("non-link clipboard text used")
}
// A Key Vault URL isn't offered: only lazysecrets:// links are.
d.clip().content = "https://" + kv + "/secrets/api-key"
d.send(tea.FocusMsg{})
if d.offered() {
t.Error("Key Vault URL offered")
}
}
func TestLinkToSelectionNotOffered(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "s") // share db-password: it's what's selected
d.send(tea.FocusMsg{})
if d.offered() {
t.Fatal("offered a link to the selected secret")
}
d.keys("1", "j") // elsewhere now: the same link is worth offering
d.send(tea.FocusMsg{})
if !d.offered() || !strings.Contains(d.screen(), "platform-prod / kv-app-prod / db-password") {
t.Errorf("own link not offered after moving away:\n%s", d.screen())
}
}
func TestNoClipboardPopupOverAnotherPopup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "S") // share menu open
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key"
d.send(tea.FocusMsg{})
if d.offered() || !strings.HasPrefix(d.m.prompt.title, "Share") {
t.Error("clipboard popup replaced the share menu")
}
d.keys("esc")
d.send(tea.FocusMsg{}) // offered once the other popup is gone
if !d.offered() {
t.Error("link not offered after the share menu closed")
}
}
func TestInvalidClipboardLinkIsReported(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: "lazysecrets://azure/" + kv + "/secret/api-key"}})
if !strings.Contains(d.screen(), "Link on clipboard can't be read") || !strings.Contains(d.screen(), `unknown object type "secret"`) {
t.Fatalf("typo not reported:\n%s", d.screen())
}
d.keys("x")
if d.m.prompt != nil {
t.Error("x didn't dismiss the report")
}
}
func TestOpenPromptPrefilledFromClipboard(t *testing.T) {
t.Parallel()
link := "lazysecrets://azure/" + kv + "/secrets/api-key"
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: link}})
d.keys("x") // the clipboard popup comes first
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
d.keys("enter", "o")
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("prefilled ctrl+o opened %q", s)
}
}
func TestOpenPromptReadsClipboardFresh(t *testing.T) {
t.Parallel()
d := newDriver(t)
// Copied in another app, no focus event: not offered, but ctrl+o
// still picks it up, and so it does for a plain Key Vault URL.
d.clip().content = "https://" + kv + "/secrets/api-key"
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
d.keys("enter", "o")
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("ctrl+o didn't use the clipboard: %q", s)
}
// Not prefilled with text that isn't a reference.
d.clip().content = "hunter2"
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
if v := d.m.prompt.input.Value(); v != "" {
t.Errorf("prefilled with %q", v)
}
}
func TestClipboardCheckedOnKeysWithoutFocusEvents(t *testing.T) {
t.Parallel()
d := newDriver(t)
clock := time.Now()
d.m.now = func() time.Time { return clock }
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key"
clock = clock.Add(clipCheckEvery + time.Second)
d.keys("j") // any key, once the throttle allows
if !d.offered() {
t.Fatal("not offered after a key press")
}
d.keys("x")
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/legacy-token"
d.keys("k") // within the throttle window: not checked yet
if d.offered() {
t.Error("checked again within the throttle window")
}
clock = clock.Add(clipCheckEvery)
d.keys("j")
if !d.offered() {
t.Error("new link not offered after the window")
}
}
func TestOpenSourceLinks(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.PasteMsg{Content: "lazysecrets://azure/subscriptions/sub-dev"})
if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers {
t.Errorf("subscription link: %q, focus %d", src, d.m.focus)
}
d.send(tea.PasteMsg{Content: "lazysecrets://databricks/adb-2222222222222222.2.azuredatabricks.net"})
if src, _, _ := d.selected(); src != "dev" {
t.Errorf("workspace link: %q", src)
}
two := newDriver(t, fake.Azure(0).As("Azure:alice", "Azure · alice"), fake.Azure(0).As("Azure:bob", "Azure · bob"))
two.send(tea.PasteMsg{Content: "lazysecrets://azure/subscriptions/sub-dev"})
if !strings.Contains(two.screen(), "2 Open with Azure · bob / platform-dev") {
t.Fatalf("no account choice:\n%s", two.screen())
}
two.keys("2")
if r, _ := two.m.sources.Selected(); r.provider != "Azure:bob" || r.source.Name != "platform-dev" {
t.Errorf("chose %s/%s", r.provider, r.source.Name)
}
}
func TestHandedOverLinkAndActivity(t *testing.T) {
t.Parallel()
touched := 0
d := newDriverOpts(t, Options{OnActive: func() { touched++ }})
d.keys("j")
d.send(tea.FocusMsg{})
if touched != 2 {
t.Errorf("OnActive called %d times, want 2", touched)
}
r, _ := ref.Parse("lazysecrets://azure/kv-shared-prod.vault.azure.net/secrets/smtp-password")
d.send(OpenLink(r))
if d.m.prompt == nil || !strings.Contains(d.screen(), "Open link") {
t.Fatalf("handed-over link not offered:\n%s", d.screen())
}
d.keys("o")
if _, c, s := d.selected(); c != "kv-shared-prod" || s != "smtp-password" {
t.Errorf("went to %s/%s", c, s)
}
}
func TestRefLabel(t *testing.T) {
t.Parallel()
d := newDriver(t)
for link, want := range map[string]string{
// The subscription from lists already loaded.
"lazysecrets://azure/kv-app-prod.vault.azure.net/keys/signing/abc123": "platform-prod / kv-app-prod / signing · key · version abc123",
"lazysecrets://azure/kv-app-prod.vault.azure.net/certificates": "platform-prod / kv-app-prod · certificates tab",
// Not loaded: the vault alone.
"lazysecrets://azure/kv-elsewhere.vault.azure.net/secrets/x": "kv-elsewhere / x",
} {
r, err := ref.Parse(link)
if err != nil {
t.Fatal(err)
}
if got := ansi.Strip(d.m.refLabel(r)); !strings.HasSuffix(got, want) {
t.Errorf("%s: %q, want …%q", link, got, want)
}
}
}
func TestCopyFromLinkStaysPut(t *testing.T) {
t.Parallel()
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
d := newDriverOpts(t, Options{}, denyingValues{alice}, bob, fake.Databricks(0))
d.keys("j") // platform-dev
before := d.m.sources.SelectedID()
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password")
d.send(OpenLink(r))
d.keys("y")
if got := d.clip().content; got != "correct-horse-battery-staple" {
t.Fatalf("clipboard = %q (alice denies, bob reads)\n%s", got, d.screen())
}
if d.m.sources.SelectedID() != before || d.m.nav != nil || strings.Contains(d.screen(), "correct-horse") {
t.Errorf("moved or revealed:\n%s", d.screen())
}
// A version, and a Databricks secret.
r, _ = ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0")
d.send(OpenLink(r))
d.keys("y")
if got := d.clip().content; got != "previous-password" {
t.Errorf("version: clipboard = %q", got)
}
r, _ = ref.Parse("lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key")
d.send(OpenLink(r))
d.keys("y")
if got := d.clip().content; got == "" || got == "previous-password" {
t.Errorf("databricks: clipboard = %q\n%s", got, d.screen())
}
}
// denyingValues refuses to read values, like an account without access.
type denyingValues struct{ *counting }
func (d denyingValues) Value(context.Context, provider.Object) (provider.SecretValue, error) {
return provider.SecretValue{}, fake.ErrForbidden
}
func TestShareWebPages(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("S") // a subscription
if !strings.Contains(d.screen(), "o Lazysecrets link") || !strings.Contains(d.screen(), "b Web page (Azure portal)") {
t.Fatalf("subscription share menu:\n%s", d.screen())
}
d.keys("o")
if got := d.clip().content; got != "lazysecrets://azure/subscriptions/sub-prod" {
t.Errorf("subscription link %q", got)
}
d.keys("3", "S", "b") // a secret's web page
if got := d.clip().content; !strings.HasPrefix(got, "https://portal.azure.com/") {
t.Errorf("secret web page %q", got)
}
}
+408
View File
@@ -0,0 +1,408 @@
package app
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// A modal is a centred popup that takes the keyboard, like lazygit's menus:
// a list of options, each with a shortcut key, or a text input.
type option struct {
key string
label string
tip string // shown under the list while the option is selected
note string // dimmed on the right of the row; the filter matches it
// star is the store key ctrl+f stars or unstars (favourites, search);
// starred shows a ★ before the label.
star string
starAccount string // the account the star is for
starred bool
// mark, if set, goes first: how much is loaded (search). reload,
// if set, runs on ctrl+r.
mark string
reload func(m *Model) tea.Cmd
id string // identifies the entry across rebuilds (search)
group string // the source row it's in (search)
kind provider.Kind // the kind of provider it's from (search)
hits []int // characters of the label the filter matched (Go to)
run func(m *Model) tea.Cmd
// header makes this a section title: not selectable, and hidden
// while a filter narrows the list.
header bool
}
type modal struct {
title string
lines []string // explanation above the options
about *ref.Ref // drawn above lines, redrawn as lists load (refLabel)
options []option
cursor int
// A text input instead of options; submit runs on enter.
input *textinput.Model
submit func(m *Model, text string) tea.Cmd
// A filter over the options: typing narrows them (all holds them
// all), so moving uses arrows or ctrl+j/k.
filter *textinput.Model
all []option
footer string // overrides the default footer
onClose func(m *Model) // runs when it closes, picked or not
}
// showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := textinput.New()
in.Prompt = "/"
in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0)
m.flash = flash{}
return m.prompt.filter.Focus()
}
// move moves the cursor by delta, skipping section headers; with nothing
// selectable that way it stays put. move(0) settles on a selectable option.
func (p *modal) move(delta int) {
ok := func(i int) bool { return i >= 0 && i < len(p.options) && !p.options[i].header }
step := 1
if delta < 0 {
step = -1
}
i := max(0, min(p.cursor+delta, len(p.options)-1))
for i >= 0 && i < len(p.options) && !ok(i) {
i += step
}
switch {
case ok(i):
p.cursor = i
case !ok(p.cursor): // e.g. after filtering: first selectable
for p.cursor = 0; p.cursor < len(p.options) && !ok(p.cursor); p.cursor++ {
}
}
}
// counter is "3 of 12" over the selectable options.
func (p *modal) counter() string {
n, at := 0, 0
for i, o := range p.options {
if o.header {
continue
}
n++
if i == p.cursor {
at = n
}
}
if n == 0 {
return ""
}
return fmt.Sprintf("%d of %d", at, n)
}
// refilter narrows the options to fuzzy matches of the filter. Matches
// stay under their section headers, best first within each section, and
// sections without matches drop out.
func (p *modal) refilter() {
q := p.filter.Value()
if q == "" {
p.options = p.all
p.cursor = max(0, min(p.cursor, len(p.options)-1))
p.move(0)
return
}
items := make([]match.Item, len(p.all))
for i, o := range p.all {
label := ansi.Strip(o.label)
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameEnd: utf8.RuneCountInString(label)}
}
rank := map[int]int{} // matching options: their place in the ranking
for n, h := range match.Rank(match.Parse(q), items) {
if !p.all[h.Index].header {
rank[h.Index] = n
}
}
p.options = nil
var section []int
header := -1
flush := func() {
if len(section) == 0 {
return
}
slices.SortStableFunc(section, func(a, b int) int { return rank[a] - rank[b] })
if header >= 0 {
p.options = append(p.options, p.all[header])
}
for _, i := range section {
p.options = append(p.options, p.all[i])
}
}
for i, o := range p.all {
if o.header {
flush()
section, header = nil, i
continue
}
if _, ok := rank[i]; ok {
section = append(section, i)
}
}
flush()
p.cursor = max(0, min(p.cursor, len(p.options)-1))
p.move(0)
}
// showMenu opens a menu. Every menu ends with an x Dismiss row.
func (m *Model) showMenu(title string, lines []string, options []option) {
options = append(options, option{key: "x", label: "Dismiss", run: func(*Model) tea.Cmd { return nil }})
m.prompt = &modal{title: title, lines: lines, options: options}
m.flash = flash{}
}
// handleModal routes keys while a modal is shown.
func (m *Model) handleModal(msg tea.KeyPressMsg) tea.Cmd {
p := m.prompt
km := m.keys
textField := p.input != nil || p.filter != nil
closeIt := func() {
m.prompt = nil
if p.onClose != nil {
p.onClose(m)
}
}
if (textField && key.Matches(msg, km.PopupClose)) || (!textField && key.Matches(msg, km.MenuClose)) {
closeIt()
return nil
}
if p.input != nil {
if key.Matches(msg, km.PopupSelect) {
m.prompt = nil
return p.submit(m, p.input.Value())
}
in, cmd := p.input.Update(msg)
p.input = &in
return cmd
}
if p.filter != nil {
switch {
case key.Matches(msg, km.PopupDown):
p.move(1)
case key.Matches(msg, km.PopupUp):
p.move(-1)
case key.Matches(msg, km.PopupSelect):
if len(p.options) > 0 && !p.options[p.cursor].header {
closeIt()
return p.options[p.cursor].run(m)
}
default:
in, cmd := p.filter.Update(msg)
p.filter = &in
p.refilter()
return cmd
}
return nil
}
// A menu: each entry's letter picks it (fixed, part of its label),
// before the moving keys.
for _, o := range p.options {
if o.key != "" && o.key == msg.String() {
m.prompt = nil
return o.run(m)
}
}
switch {
case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.Menu)
case key.Matches(msg, km.MenuDown):
p.cursor = min(p.cursor+1, len(p.options)-1)
case key.Matches(msg, km.MenuUp):
p.cursor = max(p.cursor-1, 0)
case key.Matches(msg, km.MenuSelect):
m.prompt = nil
return p.options[p.cursor].run(m)
}
return nil
}
// modalView draws the modal as a box sized to its content.
func (m Model) modalView() string {
p := m.prompt
var body []string
if p.about != nil {
body = append(body, " "+m.refLabel(*p.about))
}
for _, l := range p.lines {
body = append(body, " "+l)
}
if (len(p.lines) > 0 || p.about != nil) && (len(p.options) > 0 || p.input != nil) {
body = append(body, "")
}
km := m.keys
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
if p.input != nil {
body = append(body, " "+p.input.View())
foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel"))
}
// Size from all options, not the ones shown or the cursor's, so the
// popup doesn't change shape while moving or filtering.
all := p.all
if all == nil {
all = p.options
}
optText := func(o option) string {
pre := ""
if o.mark != "" {
pre = o.mark + " "
}
if o.star != "" {
mark := " "
if o.starred {
mark = m.st.Favourite.Render(star)
}
return pre + mark + o.label
}
if o.key == "" {
return o.label
}
return m.st.Label.Render(o.key) + " " + o.label
}
optW := 0
for _, o := range all {
if !o.header {
w := ansi.StringWidth(optText(o)) + 2 // "▸ " or " "
if o.note != "" {
w += 2 + ansi.StringWidth(o.note)
}
optW = max(optW, w)
}
}
optW = min(optW, max(0, m.w-8)) // notes give way to the screen
row := func(o option) string {
return withNote(m.st, optText(o), o.note, optW-2)
}
const window = 15 // rows of a filtered list
options, first, rows := p.options, 0, len(p.options)
if p.filter != nil {
body = append(body, " "+p.filter.View(), "")
foot = footer(move(km.PopupUp, km.PopupDown), hint(km.PopupSelect, "jump"), hint(km.PopupClose, "close"))
rows = min(len(all), window)
first = max(0, min(p.cursor-window/2, len(options)-window))
options = options[first:min(len(options), first+window)]
if len(options) == 0 {
body = append(body, " "+m.st.Muted.Render("no matches"))
rows--
}
}
var headers []int // body lines that are section headers, drawn once w is known
for j, o := range options {
if o.header {
if j > 0 {
body = append(body, "")
}
headers = append(headers, len(body))
body = append(body, o.label)
continue
}
if i := first + j; i == p.cursor {
sel := m.st.Selected
if m.termBlurred {
sel = m.st.SelectedBlurred
}
body = append(body, " "+panes.Highlight("▸ "+row(o), sel, optW))
} else {
body = append(body, " "+" "+row(o))
}
}
for range rows - len(options) {
body = append(body, "") // keep the height while filtering
}
if p.footer != "" {
foot = p.footer
}
w := max(ansi.StringWidth(p.title)+6, optW+4, ansi.StringWidth(foot)+6)
for _, l := range body {
w = max(w, ansi.StringWidth(l)+3)
}
if p.input != nil || p.filter != nil {
w = max(w, min(m.w-4, 72))
}
w = min(w, m.w-2)
// Section headers as rules: "─── Secrets pane ─────────".
for _, i := range headers {
title := body[i]
fill := max(1, w-2-2-3-2-ansi.StringWidth(title)) // borders, margins, "───", spaces
body[i] = " " + m.st.Muted.Render("─── ") + panes.TitleStyle(m.st, !m.termBlurred).Render(title) +
m.st.Muted.Render(" "+strings.Repeat("─", fill))
}
// The selected option's tip, wrapped; room for the longest one is kept
// so the popup doesn't change height while moving.
wrapTip := func(tip string) []string {
if tip == "" {
return nil
}
return strings.Split(ansi.Wordwrap(tip, w-4, " "), "\n")
}
tipRows := 0
for _, o := range all {
tipRows = max(tipRows, len(wrapTip(o.tip)))
}
if tipRows > 0 {
var tip []string
if p.cursor < len(p.options) {
tip = wrapTip(p.options[p.cursor].tip)
}
body = append(body, "")
for i := range tipRows {
line := ""
if i < len(tip) {
line = " " + m.st.Muted.Render(tip[i])
}
body = append(body, line)
}
}
counter := ""
if p.filter != nil {
counter = p.counter()
}
focused := !m.termBlurred // like the panes, no highlight in an unfocused window
return panes.BoxCounted(m.st, panes.TitleStyle(m.st, focused).Render(p.title), foot, counter, body, w, len(body)+2, focused)
}
// overlay draws the modal centred over the screen.
func (m Model) overlay(base string) string {
var box string
if m.search != nil {
box = m.searchViewBox()
} else {
box = m.modalView()
}
x := max(0, (m.w-lipgloss.Width(box))/2)
y := max(0, (strings.Count(base, "\n")+1-lipgloss.Height(box))/3)
return lipgloss.NewCompositor(
lipgloss.NewLayer(base),
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
).Render()
}
+93
View File
@@ -0,0 +1,93 @@
package app
import (
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// Notes: n writes a short note on the selected subscription, vault or
// secret, kept in its store record. Notes show dimmed on the right of rows
// and in the detail pane, and the filter matches them.
// withNote right-aligns a dimmed note in a row of the given width. The
// note gives way first when space is short.
func withNote(st styles.Styles, left, note string, width int) string {
if note == "" {
return left
}
room := width - ansi.StringWidth(left) - 2
if room < 4 {
return left
}
note = ansi.Truncate(note, room, "…")
pad := width - ansi.StringWidth(left) - ansi.StringWidth(note)
return left + strings.Repeat(" ", pad) + st.Muted.Render(note)
}
// editNote opens a popup to write the focused item's note.
func (m *Model) editNote() tea.Cmd {
var key, name string
var path []string
var src provider.Source
switch m.focus {
case paneSources:
s, ok := m.selectedSource()
if !ok {
m.flash = warning("select a subscription or workspace to add a note")
return nil
}
key, name, src = sourceKey(s), s.Name, s
case paneContainers:
c, ok := m.containers.Selected()
if !ok {
return nil
}
key, name, path, src = storeKey(c), c.Name, []string{c.Source.Name}, c.Source
default:
o, ok := m.detail.Secret()
if !ok {
return nil
}
c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
}
in := textinput.New()
in.Prompt = "note: "
in.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note)
in.CursorEnd()
m.prompt = &modal{
title: "Note on " + name, input: &in,
lines: []string{m.st.Muted.Render("Shown on the right of the row. Empty removes it.")},
submit: func(m *Model, text string) tea.Cmd {
note := strings.Join(strings.Fields(text), " ") // one line
if err := m.remember(key, name, path, src, func(n *store.Node) { n.Note = note }); err != nil {
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
return nil
}
m.refreshDetailNote()
return nil
},
footer: footer(hint(m.keys.PopupSelect, "save"), hint(m.keys.PopupClose, "cancel")),
}
m.flash = flash{}
return m.prompt.input.Focus()
}
// refreshDetailNote shows the note of whatever the detail pane shows.
func (m *Model) refreshDetailNote() {
if o, ok := m.detail.Secret(); ok {
m.detail.SetNote(m.store.Node(objectKey(o)).Note)
} else if c, ok := m.detail.Container(); ok {
m.detail.SetNote(m.store.Node(storeKey(c)).Note)
}
}
+93
View File
@@ -0,0 +1,93 @@
package app
import (
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func (d *driver) note(text string) {
d.t.Helper()
d.keys("n")
if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Note on ") {
d.t.Fatalf("n didn't open the note popup:\n%s", d.screen())
}
for range 40 { // clear what's there
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
}
for _, r := range text {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.keys("enter")
}
func TestNotesOnRows(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "state.json")
mem, _ := store.Load(path)
d := newDriverOpts(t, Options{Store: mem})
d.keys("3", "j")
d.note("stripe live")
d.keys("2")
d.note("app secrets")
d.keys("1")
d.note("main prod")
screen := d.screen()
for _, want := range []string{"stripe live", "app secrets", "main prod", "note stripe live"} {
if !strings.Contains(screen, want) {
t.Errorf("missing %q:\n%s", want, screen)
}
}
// The filter matches notes.
d.keys("3", "/")
d.typeText("stripe")
if _, _, s := d.selected(); s != "api-key" || d.m.secrets.Len() != 1 {
t.Errorf("filter by note: %q (%d)", s, d.m.secrets.Len())
}
d.keys("esc")
// Saved, and an empty note removes it.
again, _ := store.Load(path)
notes := 0
for _, n := range again.Nodes() {
if n.Note != "" {
notes++
}
}
if notes != 3 {
t.Errorf("saved notes = %v", again.Nodes())
}
d.keys("3")
d.note("")
if strings.Contains(d.screen(), "stripe live") {
t.Error("empty note didn't remove it")
}
}
func TestWithNoteGivesWay(t *testing.T) {
t.Parallel()
st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
t.Errorf("fits: %q", stripANSI(got))
}
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got)
}
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got)
}
}
func stripANSI(s string) string { return ansi.Strip(s) }
+240
View File
@@ -0,0 +1,240 @@
package app
import (
tea "charm.land/bubbletea/v2"
"context"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
const sopsFile = `db:
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
sops:
age:
- recipient: age1xxxx
lastmodified: "2026-10-09T10:05:29Z"
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
`
// gitRepoWith makes a git repository holding a SOPS file, with a remote.
func gitRepoWith(t *testing.T, remote string) string {
t.Helper()
root, _ := filepath.EvalSymlinks(t.TempDir())
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
{"remote", "add", "origin", remote}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("%v %s", err, out)
}
}
return root
}
func TestCurrentRepoFirst(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
other := gitRepoWith(t, "https://git.example.com/team/aaa-other.git")
mem, _ := store.Load("")
mem.Update("lazysecrets://repo/git.example.com%2Fteam%2Faaa-other", func(n *store.Node) {
n.Source, n.StarredBy = other, []string{"Repos"}
})
d := newDriverOpts(t, Options{Store: mem}, repo.New(repo.Settings{Dir: here, Starred: []string{other}}))
// Grouped: the repo it started in on a row of its own on top,
// selected, then the other repos under Repos, without it.
var rows []string
for _, r := range d.m.sources.Items() {
switch r.kind {
case rowGroup:
rows = append(rows, "group "+r.label)
case rowSource:
rows = append(rows, r.source.ID)
}
}
if want := []string{here, "group Repos", other}; !slices.Equal(rows, want) {
t.Errorf("rows %v, want %v", rows, want)
}
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
t.Errorf("no Repo · name row:\n%s", d.screen())
}
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
t.Errorf("selected %v, want the repo it started in", src.ID)
}
// Flat: it sorts first, labelled.
d.keys("`")
if !strings.Contains(d.screen(), filepath.Base(here)+" · here") {
t.Errorf("no here label in the flat list:\n%s", d.screen())
}
}
func TestRepoSource(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root, _ := filepath.EvalSymlinks(t.TempDir())
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
{"remote", "add", "origin", "https://git.example.com/team/infra.git"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("%v %s", err, out)
}
}
d := newDriverOpts(t, Options{}, []provider.Provider{repo.New(repo.Settings{Dir: root})}...)
scr := d.screen()
for _, want := range []string{"Repos", filepath.Base(root), "[2] Files", "secrets/app.yaml sops · age", "db.password"} {
if !strings.Contains(scr, want) {
t.Fatalf("missing %q:\n%s", want, scr)
}
}
d.keys("3", "f")
key := "lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password"
if !d.m.store.Node(key).StarredFor("Repos") {
t.Errorf("star not stored under the repo link; nodes: %v", d.m.store.Nodes())
}
d.keys("s")
if got := d.clip().content; got != key {
t.Errorf("shared link %q", got)
}
// Opening the link finds the repo by its remote and goes there.
d.keys("1")
r, _ := ref.Parse(key)
d.send(OpenLink(r))
d.keys("o")
if src, c, s := d.selected(); src != filepath.Base(root) || c != "secrets/app.yaml" || s != "db.password" {
t.Errorf("opened %s/%s/%s\n%s", src, c, s, d.screen())
}
}
func TestReposLoadThemselves(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := gitRepoWith(t, "https://git.example.com/team/x.git")
// Started elsewhere, so the repo isn't the selected source.
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: t.TempDir(), Paths: []string{root}}))
d.openSearch()
d.searchAll()
d.typeText("db.password")
if !strings.Contains(d.screen(), "secrets/app.yaml / db.password") {
t.Errorf("repo keys not searchable without loading them:\n%s", d.screen())
}
}
func TestGroupsStartCollapsed(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
d := newDriverOpts(t, Options{CollapseGroups: true},
fake.Azure(0), fake.Databricks(0), repo.New(repo.Settings{Dir: here}))
var rows []string
for _, r := range d.m.sources.Items() {
switch r.kind {
case rowGroup:
rows = append(rows, "group "+r.label)
case rowSource:
rows = append(rows, r.source.Name)
}
}
// Folded groups, and the repo it started in on top, selected, so its
// files show.
if want := []string{filepath.Base(here), "group Azure", "group Databricks"}; !slices.Equal(rows, want) {
t.Errorf("rows %v, want %v", rows, want)
}
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
t.Errorf("selected %v, want the repo it started in", src.ID)
}
d.keys("j", "enter") // unfold Azure
if d.sourceCount() < 2 {
t.Errorf("enter on a folded group didn't unfold it:\n%s", d.screen())
}
}
func TestCurrentRepoBeforeTheRest(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
p := repo.New(repo.Settings{Dir: here})
d := newDriverOpts(t, Options{}, fake.Azure(0), p)
// Sources asked again, nothing answered yet but the started-in repo
// on its own (provider.Current): it shows on top already.
d.m.loadSources()
src, ok := p.Current(context.Background())
if !ok {
t.Fatal("no current repo")
}
src.Provider, src.ProviderLabel = p.ID(), p.Label()
d.send(currentMsg{gen: d.m.sourcesGen, source: src})
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
t.Errorf("the started-in repo waits for the others:\n%s", d.screen())
}
}
func TestSearchAllRepos(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
a := gitRepoWith(t, "https://git.example.com/team/alpha.git")
b := gitRepoWith(t, "https://git.example.com/team/beta.git")
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Paths: []string{a, b}}))
d.openSearch()
d.send(ctrl('h'))
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for d.m.search.scope().id != kindScope+string(provider.KindRepo) {
if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatalf("no All repos scope: %v", d.m.search.scopes)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
azure := func() (n int) {
for row := range d.m.cache.containers {
if p, _ := splitSourceRow(row); p != "Repos" {
n++
}
}
return n
}
azureBefore := azure()
d.typeText("password")
var got []string
for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansiStrip(o.label))
}
got = append(got, ansiStrip(o.label))
}
if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got)
}
if azure() != azureBefore || d.m.crawl.running {
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
}
if !strings.Contains(d.screen(), "Results in all repos") {
t.Errorf("title:\n%s", d.screen())
}
}
+61
View File
@@ -0,0 +1,61 @@
package app
import (
"fmt"
"math"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// A revealed value doesn't stay on screen: it hides after the configured
// timeout, counting down in the detail pane, and when the terminal loses
// focus (switching windows, screen sharing another app).
// startRevealTimer starts the countdown for a value just revealed.
func (m *Model) startRevealTimer() tea.Cmd {
m.revealToken++
if m.revealTimeout <= 0 {
m.revealDeadline = time.Time{}
return nil
}
m.revealDeadline = m.now().Add(m.revealTimeout)
m.showHidesIn()
return m.revealTickCmd()
}
// revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd {
wait := m.revealDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.revealToken
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} })
}
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
if msg.token != m.revealToken || m.revealDeadline.IsZero() || m.detail.State() != panes.ValueShown {
return nil // hidden meanwhile, or another value shown
}
if !m.now().Before(m.revealDeadline) {
m.hideValue()
return nil
}
m.showHidesIn()
return m.revealTickCmd()
}
func (m *Model) showHidesIn() {
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds())))
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
}
// hideValue hides the revealed value and stops its countdown.
func (m *Model) hideValue() {
m.detail.Hide()
m.revealToken++
m.revealDeadline = time.Time{}
}
+95
View File
@@ -0,0 +1,95 @@
package app
import (
"context"
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// containerKey identifies a container in the UI and caches. The same vault
// seen through two accounts must not share cached lists, since each
// account may see different things.
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
// providerFor finds the provider instance a source came from. Sources
// remembered from an earlier run (favourites) may name an account that
// isn't signed in any more, or predate accounts; they fall back to the
// first provider of the same kind.
func (m Model) providerFor(src provider.Source) provider.Provider {
if p, ok := m.byID[src.Provider]; ok {
return p
}
for _, p := range m.providers {
if p.Kind() == src.Kind {
return p
}
}
return missingProvider{src}
}
// candidates lists the providers that may serve src, in configured order:
// its own provider when that is known, otherwise every provider of its kind
// that lists the source (all of that kind if their sources haven't loaded).
// Favourites saved before accounts existed, or by an account that is no
// longer signed in, take the second path.
func (m Model) candidates(src provider.Source) []provider.Provider {
if p, ok := m.byID[src.Provider]; ok {
return []provider.Provider{p}
}
var seeing, ofKind []provider.Provider
for _, p := range m.providers {
if p.Kind() != src.Kind {
continue
}
ofKind = append(ofKind, p)
r, loaded := m.sourceResults[p.ID()]
if !loaded || slices.ContainsFunc(r.sources, func(s provider.Source) bool { return s.ID == src.ID }) {
seeing = append(seeing, p)
}
}
switch {
case len(seeing) > 0:
return seeing
case len(ofKind) > 0:
return ofKind
}
return []provider.Provider{missingProvider{src}}
}
// boundTo returns src as served by p.
func boundTo(src provider.Source, p provider.Provider) provider.Source {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
return src
}
// missingProvider answers for a provider that isn't configured, e.g. a
// favourite from a provider disabled in the config.
type missingProvider struct{ src provider.Source }
func (p missingProvider) err() error {
return fmt.Errorf("%s isn't available (disabled in the config or not signed in)", p.src.Kind)
}
func (p missingProvider) Kind() provider.Kind { return p.src.Kind }
func (p missingProvider) ID() string { return p.src.Provider }
func (p missingProvider) Label() string { return string(p.src.Kind) }
func (p missingProvider) Sources(context.Context) ([]provider.Source, error) {
return nil, p.err()
}
func (p missingProvider) Containers(context.Context, provider.Source) ([]provider.Container, error) {
return nil, p.err()
}
func (p missingProvider) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
return nil, p.err()
}
func (p missingProvider) Describe(_ context.Context, o provider.Object) (provider.Object, error) {
return o, p.err()
}
func (p missingProvider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
return nil, p.err()
}
func (p missingProvider) Value(context.Context, provider.Object) (provider.SecretValue, error) {
return provider.SecretValue{}, p.err()
}
+384
View File
@@ -0,0 +1,384 @@
package app
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// ctrl+s searches everything loaded: subscriptions and workspaces, vaults
// and scopes, and the secrets, keys and certificates of every vault opened
// this session. Azure has no search across vaults, so ctrl+a in the popup
// loads the rest in the background, a few requests at a time; results
// appear as lists arrive, and the panes use them too.
const crawlWorkers = 4
// crawl loads every vault's secrets for search.
type crawl struct {
running bool
queue []crawlJob
active int
pending map[string]bool // cache keys of lists queued or loading
failed map[string]bool // cache keys of lists that couldn't be read
}
type crawlJob struct {
src *provider.Source // list its vaults or scopes
container *provider.Container // list its secrets
row string // the source's row key (containers cache key)
force bool // reload what's loaded already
}
type crawlMsg struct {
job crawlJob
containers []provider.Container
objects []provider.Object
err error
}
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group.
func (m *Model) searchEntries() (sources, items []option) {
type entry struct {
depth int
sort string
opt option
}
var es []entry
// key is the store key, so ctrl+f stars the entry; mark shows how
// much is loaded, reload (ctrl+r) loads it again.
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) {
n := m.store.Node(key)
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
}
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
row := sourceRowKey(p.ID(), src.ID)
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
for _, c := range m.cache.containers[row].value {
r, err := ref.ForContainer(c, provider.TypeSecret)
if err != nil {
continue
}
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
r, err := ref.ForObject(o, "")
if err != nil {
continue
}
suffix := accountOf(src)
if t != provider.TypeSecret {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·")
}
add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload)
}
}
}
}
}
slices.SortStableFunc(es, func(a, b entry) int { return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) })
for _, e := range es {
if e.depth == 0 {
sources = append(sources, e.opt)
} else {
items = append(items, e.opt)
}
}
return sources, items
}
// coverage is how much of everything search can see: sources whose
// vaults are listed, vaults whose secrets are loaded, and secrets. Lists
// that couldn't be read count as done.
type coverage struct {
sources, sourcesListed int
vaults, vaultsLoaded int
secrets, unreadable int
}
func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
}
func (m *Model) searchCoverage() coverage { return m.coverageOf("") }
// coverageOf is searchCoverage for one kind of provider ("" for all).
func (m *Model) coverageOf(kind provider.Kind) coverage {
var c coverage
for _, p := range m.providers {
if kind != "" && p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
row := sourceRowKey(p.ID(), src.ID)
c.sources++
e, ok := m.cache.containers[row]
if ok || m.crawl.failed[row] {
c.sourcesListed++
}
if m.crawl.failed[row] {
c.unreadable++
}
for _, v := range e.value {
c.vaults++
key := containerKey(v) + "|" + string(provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++
c.secrets += len(objs.value)
} else if m.crawl.failed[key] {
c.vaultsLoaded++
c.unreadable++
}
}
}
}
return c
}
// searchLines says what the search covers.
func (m *Model) searchLines() []string {
c := m.searchCoverage()
switch {
case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
len(m.crawl.queue)+m.crawl.active))}
case c.complete():
s := fmt.Sprintf("Searching everything: %s, %s, %s",
plural(c.sources, "source"), plural(c.vaults, "vault"), plural(c.secrets, "secret"))
if c.unreadable > 0 {
s += fmt.Sprintf(" (%s unreadable)", plural(c.unreadable, "list"))
}
return []string{m.st.Success.Render("✓") + " " + m.st.Notice.Render(s)}
}
s := fmt.Sprintf("Only what's loaded: %d/%d vaults", c.vaultsLoaded, c.vaults)
if c.sourcesListed < c.sources {
s += fmt.Sprintf(", %d/%d sources listed", c.sourcesListed, c.sources)
}
return []string{m.st.Warning.Render("⚠ " + s + "." + withKey(m.loadAllKey(), " %s loads the rest."))}
}
func plural(n int, word string) string {
if n == 1 {
return "1 " + word
}
return fmt.Sprintf("%d %ss", n, word)
}
// startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf("") }
// startCrawlOf is startCrawl for one kind of provider ("" for all).
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
for _, p := range m.providers {
if kind != "" && p.Kind() != kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
m.queueSource(src, false)
}
}
return m.crawlStart()
}
// loadSource loads (again) a subscription's vaults and their secrets.
func (m *Model) loadSource(src provider.Source) tea.Cmd {
m.queueSource(src, true)
return m.crawlStart()
}
// loadContainer loads (again) a vault's secrets.
func (m *Model) loadContainer(c provider.Container) tea.Cmd {
m.queueContainer(c, sourceRowKey(c.Source.Provider, c.Source.ID), true)
return m.crawlStart()
}
// crawlStart runs what's queued, and shows it in the popup.
func (m *Model) crawlStart() tea.Cmd {
cmd := m.crawlNext()
m.refreshSearch()
return cmd
}
// queueSource queues listing src's vaults or, with them listed and not
// force, loading their secrets.
func (m *Model) queueSource(src provider.Source, force bool) {
row := sourceRowKey(src.Provider, src.ID)
if e, ok := m.cache.containers[row]; ok && !force {
for _, c := range e.value {
m.queueContainer(c, row, false)
}
return
}
m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
}
// queueContainer queues loading c's secrets, unless loaded and not force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok && !force {
return
}
m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
}
func (m *Model) enqueue(job crawlJob, key string) {
if m.crawl.pending == nil {
m.crawl.pending, m.crawl.failed = map[string]bool{}, map[string]bool{}
}
if m.crawl.pending[key] {
return // queued or loading already
}
m.crawl.pending[key] = true
m.crawl.queue = append(m.crawl.queue, job)
}
// crawlNext starts jobs up to crawlWorkers at a time.
func (m *Model) crawlNext() tea.Cmd {
var cmds []tea.Cmd
for m.crawl.active < crawlWorkers && len(m.crawl.queue) > 0 {
job := m.crawl.queue[0]
m.crawl.queue = m.crawl.queue[1:]
m.crawl.active++
cmds = append(cmds, m.crawlCmd(job))
}
m.crawl.running = m.crawl.active > 0 || len(m.crawl.queue) > 0
return tea.Batch(cmds...)
}
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil {
p := m.providerFor(*job.src)
src := *job.src
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Containers(ctx, src)
for i := range items {
items[i].Source = src
}
return crawlMsg{job: job, containers: items, err: err}
}
}
c := *job.container
p := m.providerFor(c.Source)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items {
items[i].Container = c
}
return crawlMsg{job: job, objects: items, err: err}
}
}
func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active--
key := msg.job.row
if msg.job.container != nil {
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
}
delete(m.crawl.pending, key)
switch {
case msg.err != nil:
m.crawl.failed[key] = true
case msg.job.src != nil:
delete(m.crawl.failed, key)
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
for _, c := range msg.containers {
m.queueContainer(c, key, msg.job.force)
}
default:
delete(m.crawl.failed, key)
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
}
cmd := m.crawlNext()
m.refreshSearch()
return cmd
}
// sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string {
if m.crawl.pending[row] {
return m.st.Muted.Render("◌")
}
if m.crawl.failed[row] {
return m.st.Error.Render("✗")
}
e, ok := m.cache.containers[row]
if !ok {
return m.st.Muted.Render("○")
}
loaded, loading := 0, false
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch {
case loading:
return m.st.Muted.Render("◌")
case loaded == len(e.value):
return m.st.Success.Render("●")
case loaded > 0:
return m.st.Warning.Render("◐")
}
return m.st.Muted.Render("○")
}
// vaultMark marks a vault in search only when its secrets can't be read
// (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string {
key := containerKey(c) + "|" + string(provider.TypeSecret)
switch {
case m.crawl.pending[key]:
return m.st.Muted.Render("◌")
case m.crawl.failed[key]:
return m.st.Error.Render("✗")
}
return " "
}
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+406
View File
@@ -0,0 +1,406 @@
package app
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi"
)
func (d *driver) openSearch() {
d.t.Helper()
d.send(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
if d.m.search == nil {
d.t.Fatalf("no search popup:\n%s", d.screen())
}
}
func ctrl(r rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl} }
// searchAll picks All on the left and goes back to the results.
func (d *driver) searchAll() {
d.send(ctrl('h'))
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for d.m.search.scope().id != allSources {
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
d.send(ctrl('l'))
}
func TestSearchLoaded(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("j") // platform-dev: away from what's loaded
d.openSearch()
for _, want := range []string{"Sources", "Results", "All", "platform-dev"} {
if !strings.Contains(d.screen(), want) {
t.Fatalf("missing %q:\n%s", want, d.screen())
}
}
d.typeText("api-key")
if !strings.Contains(d.screen(), "no matches here · All searches everything") {
t.Errorf("no hint to search everything:\n%s", d.screen())
}
d.searchAll()
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
t.Fatalf("loaded secret not found:\n%s", d.screen())
}
d.keys("enter")
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Errorf("jumped to %s/%s/%s", src, c, s)
}
if d.m.search != nil {
t.Error("popup still open")
}
}
func TestSearchLoadsAsYouType(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch() // on the source the cursor is on: platform-prod
// Typing loads the scope: that source, without ctrl+a.
d.typeText("smtp")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
t.Fatalf("not found in a vault that wasn't loaded:\n%s", d.screen())
}
if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; !ok {
t.Error("loaded list not in the panes' cache")
}
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; ok {
t.Error("another source loaded too")
}
// On All: everything.
d.keys("tab") // to the scopes
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
d.typeText("-")
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
}
if d.m.search.input.Value() != "smtp-" {
t.Error("query lost while loading")
}
if !strings.Contains(d.screen(), "unreadable") { // kv-restricted refuses
t.Errorf("unreadable vaults not counted:\n%s", d.screen())
}
// Once: the next key doesn't ask for the list that failed again;
// ctrl+a still does, on purpose.
d.typeText("p")
if d.m.crawl.running {
t.Error("typing on reloads what failed")
}
}
func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.keys("tab") // to Sources
sel := func() string { return d.m.search.scopes[d.m.search.src].id }
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for !strings.HasPrefix(sel(), "platform-dev") {
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatal("platform-dev not in sources")
}
}
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
}
if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
}
d.send(ctrl('r'))
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
}
for _, o := range d.m.search.shown {
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
}
}
if len(d.m.search.shown) == 0 {
t.Error("nothing shown after loading platform-dev")
}
if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; ok {
t.Error("loaded another subscription too")
}
}
func TestSearchResultsStayPut(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
ids := func() []string {
var out []string
for _, o := range d.m.search.shown {
out = append(out, o.id)
}
return out
}
before, at := ids(), d.m.search.shown[d.m.search.res].id
d.send(ctrl('a'))
after := ids()
if len(after) <= len(before) {
t.Fatalf("nothing loaded: %d → %d", len(before), len(after))
}
for i, id := range before {
if after[i] != id {
t.Errorf("row %d changed from %q to %q", i, id, after[i])
}
}
if got := d.m.search.shown[d.m.search.res].id; got != at {
t.Errorf("cursor moved from %q to %q", at, got)
}
}
func TestStarFromSearch(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.typeText("api-key")
d.send(ctrl('f'))
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
t.Errorf("ctrl+f in search didn't star:\n%s", d.screen())
}
}
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch()
v := d.m.search
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
t.Errorf("source not preselected: %q", got)
}
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen())
}
// From the Sources pane: just the source.
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
d.openSearch()
v = d.m.search
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
t.Errorf("source not preselected: %q", got)
}
}
func TestSearchHint(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.searchAll()
if !strings.Contains(d.screen(), "vaults loaded · ctrl+a loads everything") {
t.Errorf("no hint on All:\n%s", d.screen())
}
d.send(ctrl('a'))
if strings.Contains(d.screen(), "ctrl+a loads everything") {
t.Errorf("hint after loading everything:\n%s", d.screen())
}
}
func TestGoToTabsAndModes(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "f", "j", "f") // two favourites
d.keys("F")
v := d.m.search
if v.tab != tabFavs || v.typing {
t.Fatalf("F: tab %d, typing %v", v.tab, v.typing)
}
d.keys("j") // browsing: j moves, doesn't type
if v.res != 1 || v.input.Value() != "" {
t.Errorf("j: cursor %d, input %q", v.res, v.input.Value())
}
d.keys("]")
if v.tab != tabRecent {
t.Errorf("] → tab %d, want Recent", v.tab)
}
d.keys("]", "]")
if v.tab != tabFavs {
t.Errorf("] wraps to tab %d, want Favourites", v.tab)
}
d.keys("[")
if v.tab != tabSearch || !strings.Contains(d.screen(), "Scope") {
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
}
d.keys("/")
d.typeText("shared")
if !v.typing || len(v.shown) == 0 {
t.Fatalf("typing: %v, %d results", v.typing, len(v.shown))
}
d.keys("esc") // clears, back to browsing
if v.typing || v.input.Value() != "" || d.m.search == nil {
t.Errorf("esc while typing: typing %v, input %q, open %v", v.typing, v.input.Value(), d.m.search != nil)
}
d.keys("esc") // closes
if d.m.search != nil {
t.Error("esc while browsing didn't close")
}
}
func TestSearchHighlightsMatches(t *testing.T) {
t.Parallel()
// Icons: labels start with a multi-byte glyph, where byte offsets and
// characters differ.
d := newDriverOpts(t, Options{Icons: true})
d.openSearch()
d.typeText("dbpw")
if len(d.m.search.shown) == 0 {
t.Fatal("no results")
}
o := d.m.search.shown[0]
plain := []rune(ansiStrip(o.label))
var got string
for _, h := range o.hits {
got += string(plain[h])
}
if got != "dbpw" {
t.Errorf("hits %v spell %q in %q", o.hits, got, string(plain))
}
with := d.m.searchViewBox()
for i := range d.m.search.shown {
d.m.search.shown[i].hits = nil
}
if with == d.m.searchViewBox() {
t.Error("matched characters not styled")
}
}
func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F")
bar := lastLine(d.screen())
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want)
}
}
d.keys("/")
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar)
}
}
func TestHelpFromPopups(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F", "?")
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" || d.m.prompt.options[0].label != "Go to" {
t.Fatalf("? in Go to:\n%s", d.screen())
}
if !strings.Contains(d.screen(), "switch list") { // tab: not on the help bar
t.Errorf("Go to's keys missing:\n%s", d.screen())
}
var sections []string
for _, o := range d.m.prompt.options {
if o.header {
sections = append(sections, o.label)
}
if strings.Contains(ansiStrip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
}
}
if strings.Join(sections, ",") != "Go to,While typing, after /" {
t.Errorf("sections %v", sections)
}
d.keys("esc") // back to Go to, where it was
if d.m.search == nil || d.m.search.tab != tabFavs || d.m.prompt != nil {
t.Errorf("not back in Go to: search %v, prompt %v", d.m.search != nil, d.m.prompt != nil)
}
// Picking a key in the list runs it in Go to.
d.keys("?")
d.typeText("next tab")
d.keys("enter")
if d.m.search == nil || d.m.search.tab != tabRecent {
t.Errorf("running ] from ? didn't switch Go to's tab")
}
// Menus too.
d.keys("esc", "3", "S", "?")
if d.m.prompt == nil || d.m.prompt.options[0].label != "This menu" {
t.Fatalf("? in a menu:\n%s", d.screen())
}
for _, o := range d.m.prompt.options {
if o.header && o.label != "This menu" {
t.Errorf("menu help has section %q", o.label)
}
}
d.keys("esc")
if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Share") {
t.Errorf("not back in the share menu:\n%s", d.screen())
}
}
func TestHelpRunsTypingKeysInGoTo(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("ctrl+s", "esc") // Go to on Search, browsing
before := d.m.search.results
d.keys("?")
d.typeText("ctrl+h") // "sources", a typing key
d.keys("enter")
if d.m.search == nil || !d.m.search.typing || d.m.search.results || !before {
t.Errorf("typing key from ? didn't run: typing %v, results %v", d.m.search != nil && d.m.search.typing, d.m.search.results)
}
}
func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLine(d.screen()))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar)
}
}
func TestLoadWithEmptyResults(t *testing.T) {
t.Parallel()
c := &counting{Provider: fake.Azure(0)}
d := newDriverOpts(t, Options{}, c)
d.openSearch()
d.send(ctrl('h')) // pick platform-dev, never opened, in the scopes
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) {
var labels []string
for _, o := range d.m.search.scopes {
labels = append(labels, ansiStrip(o.label))
}
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
d.send(ctrl('l'))
if len(d.m.search.shown) != 0 || !d.m.search.results {
t.Fatalf("want empty results with the cursor there: %d shown", len(d.m.search.shown))
}
before := c.containers
d.send(ctrl('r')) // in the results, nothing selected: the source
if c.containers == before {
t.Error("ctrl+r with empty results didn't load the source")
}
if len(d.m.search.shown) == 0 {
t.Errorf("nothing shown after loading:\n%s", d.screen())
}
}
+701
View File
@@ -0,0 +1,701 @@
package app
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// Go to is one popup for finding a place and going there. Tabs at the
// top: Favourites and Recent (from the store, so they work before
// anything loads) are one list each; Search has two lists side by side,
// the scope on the left (All, or one source with how much of it is
// loaded: ○ ◐ ● ◌ ✗) and what's in it on the right.
//
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
// star, / to type) or typing a filter (ctrl+ letters). esc while typing
// clears the text and goes back to browsing; esc while browsing closes.
// F opens it on Favourites and R on Recent, browsing; search on Search,
// typing.
//
// The results don't move by themselves: as lists load, rows already shown
// keep their order and new ones come after them, with the cursor on the
// same entry. Starring keeps an entry where it is, its ★ showing the
// change. Only typing or picking another tab or scope re-sorts them.
type searchView struct {
input textinput.Model
typing bool // the text field has the keyboard
tab int // tabFavs, tabRecent or tabSearch
results bool // in Search, the results have the cursor (else the scopes)
scopes []option // All, then the sources (Search)
items []option // everything loaded, unfiltered
favs []option // starred, as when the popup opened
recent []option // recently used, as when the popup opened
shown []option // the tab's (or scope's) entries matching the input
src int // cursor in scopes
res int // cursor in shown
// loaded are the scopes a search has loaded since the popup opened:
// typing loads the scope once, so lists that fail aren't asked for
// again on every key.
loaded map[string]bool
}
const (
tabFavs = iota
tabRecent
tabSearch
tabCount
)
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
const (
scopeFavs = "#favs"
scopeRecent = "#recent"
allSources = "#all"
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos
)
const goToTitle = "Go to"
// showSearch opens Go to on Search, typing, in the source you're in
// (else All), with the selected vault under the cursor.
func (m *Model) showSearch() tea.Cmd {
cmd := m.openGoTo(tabSearch, true)
m.preselect()
return cmd
}
// showFavourites opens Go to on Favourites; showRecent on Recent. Both
// start browsing: they're short lists.
func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := textinput.New()
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries()
m.search = v
m.fillSearch(false)
m.flash = flash{}
return m.setTyping(typing)
}
// setTyping gives the keyboard to the text field, or takes it back.
func (m *Model) setTyping(on bool) tea.Cmd {
v := m.search
v.typing = on
if on {
v.input.Prompt, v.input.Placeholder = "/", "type to filter"
return v.input.Focus()
}
v.input.Blur()
v.input.Prompt = "" // the placeholder names the key
if v.input.Value() != "" {
v.input.Prompt = "/"
}
v.input.Placeholder = withKey(m.keys.GoFilter, "%s to filter")
return nil
}
// scope is what the results come from: the tab, or in Search the scope
// picked on the left.
func (v *searchView) scope() option {
switch v.tab {
case tabFavs:
return option{id: scopeFavs}
case tabRecent:
return option{id: scopeRecent}
}
return v.scopes[v.src]
}
// onSource reports whether the results come from one source.
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
// preselect starts where the user is: the selected source picked on the
// left, and the selected vault or scope under the cursor on the right.
func (m *Model) preselect() {
v := m.search
row := m.sources.SelectedID()
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
if i < 0 {
return
}
v.src = i
m.fillSearch(false)
if m.focus == paneSources {
return
}
if c, ok := m.containers.Selected(); ok {
key := storeKey(c)
if j := slices.IndexFunc(v.shown, func(o option) bool { return o.star == key }); j >= 0 {
v.res = j
}
}
}
// fillSearch (re)builds the lists from what's loaded. keep keeps the
// order of rows already shown (lists arrived); otherwise the results are
// ranked afresh (the query, tab or scope changed).
func (m *Model) fillSearch(keep bool) {
v := m.search
atSrc, atRes := "", ""
if v.src < len(v.scopes) {
atSrc = v.scopes[v.src].id
}
if v.res < len(v.shown) {
atRes = v.shown[v.res].id
}
prev := map[string]int{}
for i, o := range v.shown {
prev[o.id] = i
}
sources, items := m.searchEntries()
all := option{label: "All", id: allSources, mark: m.coverageMark("")}
scopes := []option{all}
// All of one kind, so a search can stay on repos (local, quick)
// without asking Azure for every vault; only with several kinds.
var kinds []provider.Kind
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) {
kinds = append(kinds, p.Kind())
}
}
if len(kinds) > 1 {
for _, k := range kinds {
scopes = append(scopes, option{
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
id: kindScope + string(k), kind: k, mark: m.coverageMark(k),
})
}
}
v.scopes, v.items = append(scopes, sources...), items
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
v.shown = v.match()
if keep {
v.shown = stableOrder(v.shown, prev)
if i := slices.IndexFunc(v.shown, func(o option) bool { return o.id == atRes }); i >= 0 {
v.res = i
}
}
v.res = max(0, min(v.res, len(v.shown)-1))
}
// kindScopeLabel names the scope of all of a kind's sources.
func kindScopeLabel(k provider.Kind) string {
if k == provider.KindRepo {
return "All repos"
}
return "All " + string(k)
}
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
func (m *Model) coverageMark(kind provider.Kind) string {
switch c := m.coverageOf(kind); {
case c.complete():
return m.st.Success.Render("●")
case c.vaultsLoaded > 0:
return m.st.Warning.Render("◐")
}
return m.st.Muted.Render("○")
}
// scopeKind is the kind a scope covers all of, if it's one of those.
func (v *searchView) scopeKind() (provider.Kind, bool) {
k, ok := strings.CutPrefix(v.scope().id, kindScope)
if !ok || v.tab != tabSearch {
return "", false
}
return provider.Kind(k), true
}
// match lists the scope's entries matching the input, best first.
func (v *searchView) match() []option {
var cand []option
switch sc := v.scope(); sc.id {
case scopeFavs:
cand = v.favs
case scopeRecent:
cand = v.recent
default:
kind, ofKind := v.scopeKind()
for _, o := range v.items {
if sc.id == allSources || ofKind && o.kind == kind || sc.group != "" && o.group == sc.group {
cand = append(cand, o)
}
}
}
q := v.input.Value()
if q == "" {
return slices.Clone(cand)
}
items := make([]match.Item, len(cand))
for i, o := range cand {
label := ansi.Strip(o.label)
start, end := nameRange(label)
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameStart: start, NameEnd: end}
}
var out []option
for _, h := range match.Rank(match.Parse(q), items) {
o := cand[h.Index]
// Hits in the label are highlighted; ones in the note just count.
n := utf8.RuneCountInString(ansi.Strip(o.label))
o.hits = nil
for _, p := range h.Pos {
if p < n {
o.hits = append(o.hits, p)
}
}
out = append(out, o)
}
return out
}
// nameRange is where the item's own name is in a Go to label, in runes:
// the last part of "sub / vault / secret · account", which matches count
// most for.
func nameRange(label string) (int, int) {
end := len(label)
if i := strings.LastIndex(label, " · "); i >= 0 {
end = i
}
start := 0 // a source: all of it (an icon doesn't match letters)
if i := strings.LastIndex(label[:end], " / "); i >= 0 {
start = i + len(" / ")
}
return utf8.RuneCountInString(label[:start]), utf8.RuneCountInString(label[:end])
}
// refreshSearch updates an open search as lists arrive.
func (m *Model) refreshSearch() {
if m.search != nil {
m.fillSearch(true)
}
}
// stableOrder puts entries shown before (prev) in their earlier order,
// new ones after them in the order given.
func stableOrder(opts []option, prev map[string]int) []option {
out := slices.Clone(opts)
slices.SortStableFunc(out, func(a, b option) int {
ia, okA := prev[a.id]
ib, okB := prev[b.id]
switch {
case okA && okB:
return ia - ib
case okA:
return -1
case okB:
return 1
}
return 0
})
return out
}
func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
v := m.search
km := m.keys
// The actions shared by both modes, by their keys in the mode.
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
if v.typing {
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
if key.Matches(msg, km.PopupClose) {
// Back to browsing, the filter cleared.
if v.input.Value() != "" {
v.input.SetValue("")
v.res = 0
m.fillSearch(false)
}
return m.setTyping(false)
}
} else {
switch {
case key.Matches(msg, km.GoClose):
m.search = nil
return nil
case key.Matches(msg, km.GoFilter):
return m.setTyping(true)
case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.GoTo)
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
d := 1
if key.Matches(msg, km.GoPrevTab) {
d = tabCount - 1
}
v.tab = (v.tab + d) % tabCount
v.res, v.results = 0, true
m.fillSearch(false)
return nil
}
}
inSearch := v.tab == tabSearch
moveSrc := func(d int) {
v.src = max(0, min(v.src+d, len(v.scopes)-1))
v.res = 0
m.fillSearch(false)
}
switch {
case inSearch && key.Matches(msg, k.switchList):
v.results = !v.results
return nil
case inSearch && key.Matches(msg, k.sources):
v.results = false
return nil
case inSearch && key.Matches(msg, k.results):
v.results = true
return nil
case key.Matches(msg, k.up):
if v.results {
v.res = max(0, v.res-1)
} else {
moveSrc(-1)
}
return nil
case key.Matches(msg, k.down):
if v.results {
v.res = max(0, min(v.res+1, len(v.shown)-1))
} else {
moveSrc(1)
}
return nil
case key.Matches(msg, k.sel):
switch {
case v.results && v.res < len(v.shown):
m.search = nil
return v.shown[v.res].run(m)
case !v.results && v.onSource():
m.search = nil
return v.scope().run(m)
case !v.results:
v.results = true // All: on to the results
}
return nil
case inSearch && key.Matches(msg, k.load):
if o, ok := v.selected(); ok && o.reload != nil {
return o.reload(m) // the selected source, or a result's vault
}
// Nothing selected (no results yet, or All): the scope.
if v.onSource() {
return v.scope().reload(m)
}
kind, _ := v.scopeKind()
return m.startCrawlOf(kind)
case inSearch && key.Matches(msg, k.loadAll):
kind, _ := v.scopeKind()
return m.startCrawlOf(kind)
case key.Matches(msg, k.star):
if o, ok := v.selected(); ok && o.star != "" {
m.toggleStarKey(o.star, o.starAccount)
// The entry stays where it is, its ★ showing the change.
on := m.store.Node(o.star).StarredFor(o.starAccount)
for _, list := range [][]option{v.favs, v.recent} {
for i := range list {
if list[i].star == o.star && list[i].starAccount == o.starAccount {
list[i].starred = on
}
}
}
m.fillSearch(true)
}
return nil
}
if !v.typing {
return nil
}
before := v.input.Value()
in, cmd := v.input.Update(msg)
v.input = in
if v.input.Value() != before {
v.res = 0
m.fillSearch(false)
return tea.Batch(cmd, m.searchLoads())
}
return cmd
}
// searchLoads loads what a search in the Search tab covers, like the
// load-all key: everything on All, else the source picked. Only what
// isn't loaded yet, once a popup; repos load by themselves anyway.
func (m *Model) searchLoads() tea.Cmd {
v := m.search
if v.tab != tabSearch || strings.TrimSpace(v.input.Value()) == "" {
return nil
}
sc := v.scope()
if v.loaded[sc.id] {
return nil
}
if v.loaded == nil {
v.loaded = map[string]bool{}
}
v.loaded[sc.id] = true
if kind, ok := v.scopeKind(); ok {
return m.startCrawlOf(kind)
}
if !v.onSource() {
return m.startCrawl()
}
providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources {
if src.ID == sourceID {
src.Provider, src.ProviderLabel = providerID, m.byID[providerID].Label()
m.queueSource(src, false)
return m.crawlStart()
}
}
return nil
}
// selected is the entry under the cursor in the focused list (a scope
// that isn't one source is none).
func (v *searchView) selected() (option, bool) {
if v.results {
if v.res < len(v.shown) {
return v.shown[v.res], true
}
return option{}, false
}
if v.onSource() {
return v.scope(), true
}
return option{}, false
}
// toggleStarKey stars or unstars a store record for an account and
// re-sorts the panes.
func (m *Model) toggleStarKey(key, account string) {
if err := m.store.Update(key, func(n *store.Node) { n.ToggleStar(account) }); err != nil {
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
return
}
m.resortStarred()
}
// searchHint tops the results when what they come from isn't all
// loaded, saying how to load it.
func (m Model) searchHint() string {
v := m.search
if m.crawl.running {
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
}
if v.tab != tabSearch {
return "" // from the store: nothing to load
}
if kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
c := m.coverageOf(kind)
if c.complete() {
return ""
}
return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", c.vaultsLoaded, c.vaults) + withKey(m.loadAllKey(), " · %s loads everything"))
}
src := v.scopes[v.src]
e, listed := m.cache.containers[src.group]
name := ansi.Strip(src.label)
if !listed {
if m.crawl.failed[src.group] {
return ""
}
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
}
loaded := 0
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) {
return ""
}
return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", loaded, len(e.value)) + withKey(m.loadKey(), " · %s loads "+name))
}
// loadKey and loadAllKey are the keys for loading in the current mode.
func (m Model) loadKey() key.Binding {
if m.search.typing {
return m.keys.SearchLoad
}
return m.keys.GoLoad
}
func (m Model) loadAllKey() key.Binding {
if m.search.typing {
return m.keys.SearchLoadAll
}
return m.keys.GoLoadAll
}
// searchWidth is most of the screen: paths are long.
func searchWidth(screen int) int {
return max(40, min(screen-4, screen*9/10))
}
// searchViewBox draws the popup.
func (m Model) searchViewBox() string {
v := m.search
focused := !m.termBlurred
w := searchWidth(m.w)
listH := max(5, min(m.h-10, 14))
srcW := 10
for _, o := range v.scopes {
srcW = max(srcW, ansi.StringWidth(ansi.Strip(o.label))+ansi.StringWidth(o.note)+12) // marks, gap, borders
}
srcW = min(srcW, (w-4)*2/5)
resW := w - 4 - srcW
if v.tab != tabSearch {
resW = w - 4 // one list
}
row := func(o option, width int, cursor, active bool) string {
mark := o.mark
if mark == "" {
mark = " "
}
star := " "
if o.starred {
star = m.st.Favourite.Render("★ ")
}
label := panes.MarkMatches(o.label, o.hits, m.st.Match)
text := withNote(m.st, mark+" "+star+label, o.note, width-4) // borders, cursor column
if !cursor {
return " " + text
}
sel := m.st.Selected
if !active || !focused {
sel = m.st.SelectedBlurred
}
return " " + panes.Highlight("▸"+text, sel, width-3) // inside the borders
}
window := func(n, cursor, h int) (int, int) {
first := max(0, min(cursor-h/2, n-h))
return first, min(n, first+h)
}
inner := listH - 2
// The picked source is a choice that stays, like a tab: it keeps the
// full highlight while the results have the cursor.
var left []string
first, last := window(len(v.scopes), v.src, inner)
for i := first; i < last; i++ {
left = append(left, row(v.scopes[i], srcW, i == v.src, true))
}
var right []string
resRows := inner
if hint := m.searchHint(); hint != "" {
right = append(right, " "+hint)
resRows--
}
first, last = window(len(v.shown), v.res, resRows)
for i := first; i < last; i++ {
right = append(right, row(v.shown[i], resW, i == v.res, v.results))
}
if len(v.shown) == 0 {
msg := "no matches"
switch {
case v.input.Value() == "" && v.scope().id == scopeFavs:
msg = "nothing starred yet" + withKey(m.keys.Favourite, ": %s in the panes stars the selected item")
case v.input.Value() == "" && v.scope().id == scopeRecent:
msg = "nothing used yet"
case v.input.Value() == "":
msg = "nothing loaded here yet"
case v.tab == tabSearch && v.scope().id != allSources:
msg += " here · All searches everything"
case v.tab != tabSearch:
msg += withKey(m.keys.GoNextTab, " here · Search (%s) looks in everything loaded")
}
right = append(right, " "+m.st.Muted.Render(msg))
}
counter := func(i, n int) string {
if n == 0 {
return ""
}
return fmt.Sprintf("%d of %d", i+1, n)
}
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
if v.onSource() {
resultsTitle = "Results in " + ansi.Strip(v.scope().label)
}
if kind, ok := v.scopeKind(); ok {
resultsTitle = "Results in " + string(kind)
if kind == provider.KindRepo {
resultsTitle = "Results in all repos"
}
}
var lists string
if v.tab == tabSearch {
lists = lipgloss.JoinHorizontal(lipgloss.Top,
panes.BoxCounted(m.st, title("Scope", !v.results), "", counter(v.src, len(v.scopes)), left, srcW, listH, !v.results && focused),
panes.BoxCounted(m.st, title(resultsTitle, v.results), "", counter(v.res, len(v.shown)), right, resW, listH, v.results && focused),
)
} else {
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
}
body := []string{" " + v.input.View(), ""}
for _, l := range strings.Split(lists, "\n") {
body = append(body, " "+l)
}
body = append(body, "")
if v.tab == tabSearch {
for _, l := range m.searchLines() {
body = append(body, " "+l)
}
} else {
body = append(body, "") // the same height on every tab
}
km := m.keys
var foot string
switch {
case v.typing:
hints := []keyHint{move(km.PopupUp, km.PopupDown)}
if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
}
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...)
default:
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
}
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...)
}
// Tabs in the title, the current one underlined, like the panes.
ts := panes.TitleStyle(m.st, focused)
head := ts.Render(goToTitle + " ")
for i, name := range tabNames {
if i > 0 {
head += ts.Render(" - ")
}
if i == v.tab {
head += ts.Underline(true).Render(name)
} else {
head += m.st.Muted.Render(name)
}
}
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
+288
View File
@@ -0,0 +1,288 @@
package app
import (
"cmp"
"fmt"
"slices"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// The Sources pane either groups sources under a foldable header per
// provider, or lists them flat with the provider on each row.
type rowKind int
const (
rowGroup rowKind = iota
rowSource
rowLinks // vaults opened from links that no account could locate
)
type sourceRow struct {
kind rowKind
group provider.Kind // colour and icon
provider string // provider ID: one group per instance (account)
label string // group: provider label, e.g. "Azure · keb"
account string // flat source: account part of the label, if any
status string // group: source count, "…" while loading, "!" on error
collapsed bool // group only
flat bool // source only: shown without a group header
top bool // source only: the repo lazysecrets started in, on top
source provider.Source
}
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
// either; provider IDs contain "/" (Azure:user/tenant).
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
func splitSourceRow(row string) (providerID, sourceID string) {
providerID, sourceID, _ = strings.Cut(row, "|")
return providerID, sourceID
}
func sourceRowID(r sourceRow) string {
switch r.kind {
case rowGroup:
return "group:" + r.provider
case rowLinks:
return linksRowID
}
return sourceRowKey(r.provider, r.source.ID)
}
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources.
Filter: func(r sourceRow) string {
if r.kind != rowSource {
return ""
}
return r.source.Name
},
// So "data" finds Databricks workspaces and an account name finds
// its subscriptions.
FilterExtra: func(r sourceRow) string {
if r.kind != rowSource {
return ""
}
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
},
Render: func(st styles.Styles, r sourceRow, width int) string {
node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
starred = st.Favourite.Render(star)
}
switch {
case r.kind == rowLinks:
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
case r.kind == rowSource && r.top:
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
case r.kind == rowSource && r.flat:
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
if r.account != "" {
line += st.Muted.Render(" · " + r.account)
}
return withNote(st, line+here(st, r.source), node.Note, width)
case r.kind == rowSource:
// The provider's colour, so long groups stay recognisable.
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
}
arrow := "▾"
if r.collapsed {
arrow = "▸"
}
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
if r.status == "!" {
status = st.Error.Render("(!)")
}
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
},
})
}
// rebuildSources lays out the Sources pane from the per-provider results.
func (m *Model) rebuildSources() {
// While filtering, headers drop out, so rows show their provider and
// account like the flat view (and folded groups are searched too).
flat := m.flatSources || m.sources.HasFilter() || m.sourcesFavs
m.sourcesFiltered = m.sources.HasFilter()
// Tabs: everything, or just the starred ones ([ and ]).
m.sources.Title, m.sources.Tabs = "[1]", []string{"Sources", "Favourites"}
m.sources.Tab, m.sources.Empty = 0, "no sources found"
if m.sourcesFavs {
m.sources.Tab, m.sources.Empty = 1, "nothing starred"+withKey(m.keys.Favourite, ": %s on a subscription or workspace stars it")
}
var rows []sourceRow
if n := len(m.linked); n > 0 && !m.sourcesFavs {
rows = append(rows, sourceRow{kind: rowLinks, status: fmt.Sprint(n)})
}
// The repo lazysecrets started in gets a row of its own on top, so it
// opens on it; flat, it sorts first instead.
if src, ok := m.currentSource(); ok && !flat {
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
}
for _, p := range m.providers {
kind, id := p.Kind(), p.ID()
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id]
sources := r.sources
if !flat {
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
}
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
continue // repos show once some are found to have secret files
}
status := "…"
switch {
case done && r.err != nil:
status = "!"
case done:
status = fmt.Sprint(len(sources))
}
if !flat {
rows = append(rows, sourceRow{
kind: rowGroup, group: kind, provider: id, label: p.Label(),
status: status, collapsed: m.collapsed[id],
})
if m.collapsed[id] {
continue
}
}
for _, s := range m.starredSourcesFirst(sources) {
if m.sourcesFavs && !m.store.Node(sourceKey(s)).StarredFor(s.Provider) {
continue
}
rows = append(rows, sourceRow{
kind: rowSource, group: kind, provider: id, account: account,
flat: flat, source: s,
})
}
}
if flat {
// Flat: starred sources go to the very top, across providers.
slices.SortStableFunc(rows, func(a, b sourceRow) int {
return starRank(m.store, a) - starRank(m.store, b)
})
}
m.sources.SetItems(rows)
// Until the user moves, keep the cursor on the first source in
// provider order rather than a header, so the other panes fill in on
// their own. Providers answer in any order, so this can change as
// results arrive.
if !m.sourcesTouched {
m.sources.Top() // scroll back up so the first header shows
for _, r := range rows {
if r.kind == rowSource {
m.sources.Select(sourceRowID(r))
break
}
}
}
}
// currentSource is the source lazysecrets started in: from its
// provider's list once that's in, else as found on its own beforehand.
func (m Model) currentSource() (provider.Source, bool) {
for _, p := range m.providers {
r, done := m.sourceResults[p.ID()]
if !done {
if m.current != nil && m.current.Provider == p.ID() {
return *m.current, true
}
continue
}
for _, src := range r.sources {
if src.Here {
return src, true
}
}
}
return provider.Source{}, false
}
// starredSourcesFirst moves starred sources to the front, keeping the
// order otherwise. It doesn't modify sources.
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
out := slices.Clone(sources)
rank := func(s provider.Source) int {
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
}
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
return out
}
// starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int {
switch {
case r.kind == rowLinks:
return 0
case r.kind == rowSource && r.source.Here:
return 1
case r.kind == rowSource && mem.Node(sourceKey(r.source)).StarredFor(r.source.Provider):
return 2
}
return 3
}
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected()
if !ok || r.kind != rowSource {
return provider.Source{}, false
}
return r.source, true
}
// onLinks reports whether the cursor is on the Opened links row.
func (m Model) onLinks() bool {
return m.sources.SelectedID() == linksRowID
}
// toggleSourcesView switches between grouped and flat, keeping the cursor
// on the same source.
func (m *Model) toggleSourcesView() {
m.flatSources = !m.flatSources
m.rebuildSources()
}
// toggleGroup folds or unfolds the group under the cursor. It reports
// whether the cursor was on a group header.
func (m *Model) toggleGroup() bool {
r, ok := m.sources.Selected()
if !ok || r.kind != rowGroup {
return false
}
m.collapsed[r.provider] = !m.collapsed[r.provider]
m.rebuildSources()
m.sources.Select(sourceRowID(r))
return true
}
// here labels the source lazysecrets was started in, in the flat list
// (grouped, it has the "Repo · name" row on top).
func here(st styles.Styles, s provider.Source) string {
if s.Here {
return st.Muted.Render(" · here")
}
return ""
}
+200
View File
@@ -0,0 +1,200 @@
package app
import (
"context"
"slices"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
func TestGroupedSources(t *testing.T) {
t.Parallel()
d := newDriver(t)
screen := d.screen()
for _, want := range []string{"▾ Azure (2)", " platform-prod", "▾ Databricks (2)", " prod"} {
if !strings.Contains(screen, want) {
t.Errorf("missing %q:\n%s", want, screen)
}
}
if src, _, _ := d.selected(); src != "platform-prod" {
t.Errorf("startup cursor on %q, want the first source", src)
}
}
func TestFoldGroup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("k", "enter") // Azure header: fold
if !strings.Contains(d.screen(), "▸ Azure (2)") || strings.Contains(d.screen(), "platform-prod") {
t.Fatalf("Azure not folded:\n%s", d.screen())
}
if d.m.focus != paneSources {
t.Error("enter on a header moved focus")
}
d.keys("space") // unfold
if !strings.Contains(d.screen(), "platform-prod") {
t.Fatalf("Azure not unfolded:\n%s", d.screen())
}
// enter on a source still drills in.
d.keys("j", "enter")
if d.m.focus != paneContainers {
t.Error("enter on a source didn't move to vaults")
}
}
func TestToggleFlatKeepsSelection(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("j", "j", "j") // Databricks prod
d.keys("`")
if !d.m.flatSources || strings.Contains(d.screen(), "▾") {
t.Fatalf("not flat:\n%s", d.screen())
}
if !strings.Contains(d.screen(), "Databricks prod") {
t.Errorf("flat rows should show the provider:\n%s", d.screen())
}
if src, _, _ := d.selected(); src != "prod" {
t.Errorf("selection lost: %q", src)
}
d.keys("`")
if src, _, _ := d.selected(); d.m.flatSources || src != "prod" {
t.Errorf("back to grouped: flat=%v selected=%q", d.m.flatSources, src)
}
}
func TestFlatSourcesOption(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{FlatSources: true})
if strings.Contains(d.screen(), "▾") || d.m.sources.Len() != 4 {
t.Errorf("FlatSources ignored:\n%s", d.screen())
}
}
func TestGroupHeaderShowsProviderError(t *testing.T) {
t.Parallel()
d := newDriver(t, failingSources{fake.Azure(0)}, fake.Databricks(0))
if !strings.Contains(d.screen(), "▾ Azure (!)") {
t.Errorf("no error marker:\n%s", d.screen())
}
if src, _, _ := d.selected(); src != "prod" {
t.Errorf("cursor should skip to the first working source, got %q", src)
}
}
// slowSources delivers Azure's sources after Databricks', like the real
// Azure provider (network) versus Databricks (a local file).
func TestStartupPrefersFirstProviderWhenItAnswersLast(t *testing.T) {
t.Parallel()
m := New([]provider.Provider{fake.Azure(0), fake.Databricks(0)}, Options{Clipboard: &fakeClipboard{}})
d := &driver{t: t, m: m}
d.send(tea.WindowSizeMsg{Width: 100, Height: 30})
d.send(sourcesMsg{gen: m.sourcesGen, provider: "Databricks", sources: mustSources(t, fake.Databricks(0))})
if src, _, _ := d.selected(); src != "prod" {
t.Fatalf("before Azure answers: %q", src)
}
d.send(sourcesMsg{gen: m.sourcesGen, provider: "Azure", sources: mustSources(t, fake.Azure(0))})
if src, _, _ := d.selected(); src != "platform-prod" {
t.Errorf("cursor not moved to the first Azure source: %q", src)
}
// Once the user has moved, late results don't move the cursor.
d2 := &driver{t: t, m: New([]provider.Provider{fake.Azure(0), fake.Databricks(0)}, Options{Clipboard: &fakeClipboard{}})}
d2.send(tea.WindowSizeMsg{Width: 100, Height: 30})
d2.send(sourcesMsg{gen: d2.m.sourcesGen, provider: "Databricks", sources: mustSources(t, fake.Databricks(0))})
d2.keys("j") // dev
d2.send(sourcesMsg{gen: d2.m.sourcesGen, provider: "Azure", sources: mustSources(t, fake.Azure(0))})
if src, _, _ := d2.selected(); src != "dev" {
t.Errorf("cursor moved after the user navigated: %q", src)
}
}
func mustSources(t *testing.T, p provider.Provider) []provider.Source {
t.Helper()
s, err := p.Sources(context.Background())
if err != nil {
t.Fatal(err)
}
return s
}
func TestTwoAccounts(t *testing.T) {
t.Parallel()
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
d := newDriver(t, alice, bob)
screen := d.screen()
if !strings.Contains(screen, "▾ Azure · alice (2)") || !strings.Contains(screen, "▾ Azure · bob (2)") {
t.Fatalf("one group per account expected:\n%s", screen)
}
// Both accounts see platform-prod; bob's is a different row, and
// browsing it goes through bob's provider.
d.keys("j", "j", "j") // alice dev, bob header, bob prod
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "db-password" {
t.Fatalf("bob's platform-prod: %s/%s/%s", src, c, s)
}
c, _ := d.m.containers.Selected()
if c.Source.Provider != "Azure:bob" || d.m.containers.SelectedID() != "Azure:bob|"+c.ID {
t.Errorf("vault not tied to bob: provider %q, key %q", c.Source.Provider, d.m.containers.SelectedID())
}
if bob.objects != 1 {
t.Errorf("bob listed objects %d times, want 1", bob.objects)
}
before := alice.objects
d.keys("space") // reveal goes through bob too
if alice.values != 0 || bob.values != 1 || alice.objects != before {
t.Errorf("reveal routed wrong: alice values %d, bob values %d", alice.values, bob.values)
}
if !strings.Contains(d.screen(), "source Azure · bob / platform-prod") {
t.Errorf("detail should name the account:\n%s", d.screen())
}
// Flat: the account is shown on each row.
d.keys("`")
if !strings.Contains(d.screen(), "platform-prod · bob") {
t.Errorf("flat rows should name the account:\n%s", d.screen())
}
}
func TestFilterSourcesByProvider(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("/")
d.typeText("databr")
d.m.sources.Top()
var names []string
for range d.m.sources.Len() {
r, _ := d.m.sources.Selected()
names = append(names, r.source.Name)
d.m.sources.Move(1)
}
slices.Sort(names)
if strings.Join(names, ",") != "dev,prod" {
t.Errorf("\"databr\" matched %v, want the Databricks workspaces", names)
}
}
func TestFilteringShowsAccounts(t *testing.T) {
t.Parallel()
alice := fake.Azure(0).As("Azure:alice", "Azure · alice")
bob := fake.Azure(0).As("Azure:bob", "Azure · bob")
d := newDriver(t, alice, bob)
d.keys("k", "enter") // fold alice: filtering still finds her sources
d.keys("/")
d.typeText("dev")
screen := d.screen()
if !strings.Contains(screen, "platform-dev · ali") || !strings.Contains(screen, "platform-dev · bob") {
t.Errorf("filtered rows should name the account:\n%s", screen)
}
d.keys("esc") // clear: back to groups
if !strings.Contains(d.screen(), "▸ Azure · alice") || strings.Contains(d.screen(), "platform-dev · bob") {
t.Errorf("not grouped again after clearing:\n%s", d.screen())
}
}
+30
View File
@@ -0,0 +1,30 @@
╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮
│ ▾ Azure (2) ││ name db-password │
│ platform-prod ││ vault kv-app-prod │
│ platform-dev ││ source Azure / platform-prod │
│ ▾ Databricks (2) ││ version 3f9c2a │
│ prod ││ enabled yes │
╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │
╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │
│ kv-app-prod ││ content text/plain │
│ kv-shared-prod ││ tags env=prod, owner=data │
│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│
│ ││ lt.azure.net/secrets/db-password) │
│ ││ │
│ ││ value •••••••• (space to reveal) │
│ ││ │
╰────────────────────── 1 of 3 ─╯│ versions 3 · v to browse │
╭─ [3] Secrets - Keys - Certs ──╮│ ▸ 3f9c2a 2026-09-01 current │
│ db-password ││ 91d3e0 2026-03-01 expired │
│ api-key ││ 0a77c4 2025-09-01 disabled │
│ legacy-token (disabled) ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────── 1 of 3 ─╯╰─────────────────────────────────────────────────────────────────╯
enter open • s copy link • S share… • f star • F favourites • n note • … • ? help • q quit
+169
View File
@@ -0,0 +1,169 @@
package app
import (
"context"
"errors"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// Updater finds and installs newer releases (internal/update).
type Updater struct {
Latest func(context.Context) (update.Release, error)
Install func(context.Context, update.Release) error
}
// updateEvery is how often lazysecrets looks for a newer release on its
// own; within that, the last answer (in the state file) is used.
const updateEvery = 24 * time.Hour
type updateMsg struct {
release update.Release
err error
asked bool // the user asked (U), rather than the daily check
}
type installedMsg struct {
release update.Release
err error
}
// startUpdateCheck sets up the startup look for a newer release: the
// state file's answer when it looked less than a day ago, else a quiet
// look at the server (fetchOnStart, for Init).
func (m *Model) startUpdateCheck() {
if m.updater == nil || !m.updateCheck {
return
}
last := m.store.UpdateCheck()
if m.now().Sub(last.At) < updateEvery {
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
return
}
m.fetchOnStart = true
}
func (m Model) fetchLatest(asked bool) tea.Cmd {
latest := m.updater.Latest
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
r, err := latest(ctx)
return updateMsg{release: r, err: err, asked: asked}
}
}
// offer remembers r as the update to offer, if it's newer.
func (m *Model) offer(r update.Release) {
m.newer = nil
if update.Newer(m.version, r.Tag) {
m.newer = &r
}
}
func (m *Model) handleUpdate(msg updateMsg) tea.Cmd {
if msg.err != nil {
if msg.asked {
m.flash = flash{text: "couldn't check for a newer lazysecrets: " + msg.err.Error(), isErr: true}
}
return nil // the daily check stays quiet: offline, or the server's down
}
m.store.SetUpdateCheck(store.UpdateCheck{
At: m.now(), Tag: msg.release.Tag, Notes: msg.release.Body, URL: msg.release.URL,
})
m.offer(msg.release)
if msg.asked {
if m.newer == nil {
m.flash = done("lazysecrets " + m.versionLabel() + " is the latest (" + msg.release.Tag + ")")
return nil
}
m.showUpdate()
}
return nil
}
// update is the update key: the newer release, or a look for one.
func (m *Model) update() tea.Cmd {
switch {
case m.updater == nil:
m.flash = warning("updates aren't available in this build")
return nil
case m.newer != nil:
m.showUpdate()
return nil
case !update.Newer(m.version, "v999.0.0"): // dev builds aren't versions
m.flash = warning("this is a development build (" + m.versionLabel() + "): update it with go install or install.sh")
return nil
}
m.flash = flash{text: "looking for a newer lazysecrets…"}
return m.fetchLatest(true)
}
// showUpdate offers the newer release: its changelog, and updating.
func (m *Model) showUpdate() {
r := *m.newer
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
if notes := r.Notes(); notes != "" {
lines = append(lines, "")
for _, l := range strings.Split(notes, "\n") {
if h, ok := strings.CutPrefix(l, "### "); ok {
l = m.st.Label.Render(h)
}
lines = append(lines, l)
}
}
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" {
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
switch err := m.openURL(r.URL); {
case errors.Is(err, browser.ErrRemote):
cmd := m.copyText("link to the release", r.URL, false)
m.flash.text += " (can't open a browser over SSH)"
return cmd
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
}
return nil
}})
}
m.showMenu("lazysecrets "+r.Tag, lines, opts)
}
func (m *Model) install(r update.Release) tea.Cmd {
m.flash = flash{text: "downloading lazysecrets " + r.Tag + "…"}
install := m.updater.Install
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
return installedMsg{release: r, err: install(ctx, r)}
}
}
func (m *Model) handleInstalled(msg installedMsg) {
if msg.err != nil {
m.flash = flash{text: "update failed: " + msg.err.Error(), isErr: true}
return
}
m.newer = nil
m.updated = msg.release.Tag
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it")
}
// updateLabel is the version's suffix in the footer: a newer release to
// update to, or the one installed and waiting for a restart.
func (m Model) updateLabel() string {
switch {
case m.updated != "":
return " → " + m.updated + " (restart)"
case m.newer != nil:
return " ↑ " + m.newer.Tag + withKey(m.keys.Update, " %s")
}
return ""
}
+118
View File
@@ -0,0 +1,118 @@
package app
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// fakeUpdater offers latest and records what it installs.
type fakeUpdater struct {
latest update.Release
err error
asked int
installed string
}
func (f *fakeUpdater) updater() *Updater {
return &Updater{
Latest: func(context.Context) (update.Release, error) {
f.asked++
return f.latest, f.err
},
Install: func(_ context.Context, r update.Release) error {
f.installed = r.Tag
return nil
},
}
}
var v020 = update.Release{
Tag: "v0.2.0",
Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\ncurl …",
URL: "https://git.example.com/keb/lazysecrets/releases/tag/v0.2.0",
}
func TestUpdate(t *testing.T) {
t.Parallel()
f := &fakeUpdater{latest: v020}
mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json"))
d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), UpdateCheck: true, Store: mem})
if f.asked != 1 {
t.Fatalf("asked %d times at startup", f.asked)
}
if !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") {
t.Errorf("no update in the footer:\n%s", d.screen())
}
// U: the changelog, without the install notes; u installs.
d.keys("U")
scr := d.screen()
for _, want := range []string{"lazysecrets v0.2.0", "You have v0.1.1.", "Install with a one-liner (#7)", "Update now", "Open the release page"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
if strings.Contains(scr, "curl") {
t.Errorf("install notes shown:\n%s", scr)
}
d.keys("u")
if f.installed != "v0.2.0" {
t.Fatalf("installed %q", f.installed)
}
if !strings.Contains(d.screen(), "updated to lazysecrets v0.2.0 · restart lazysecrets to use it") ||
!strings.Contains(d.screen(), "→ v0.2.0 (restart)") {
t.Errorf("after updating:\n%s", d.screen())
}
// Within a day, the next start uses what it found: no request.
f2 := &fakeUpdater{latest: v020}
d2 := newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem})
if f2.asked != 0 || !strings.Contains(d2.screen(), "↑ v0.2.0") {
t.Errorf("asked %d times within a day:\n%s", f2.asked, d2.screen())
}
// A day later it asks again.
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-25 * time.Hour), Tag: "v0.2.0"})
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem})
if f2.asked != 1 {
t.Errorf("asked %d times a day later", f2.asked)
}
}
func TestUpdateAsked(t *testing.T) {
t.Parallel()
mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json"))
// Not looking on its own: U looks.
f := &fakeUpdater{latest: update.Release{Tag: "v0.1.1"}}
d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), Store: mem})
if f.asked != 0 {
t.Fatal("looked without updateCheck")
}
d.keys("U")
if f.asked != 1 || !strings.Contains(d.screen(), "lazysecrets v0.1.1 is the latest (v0.1.1)") {
t.Errorf("asked %d:\n%s", f.asked, d.screen())
}
// The daily check stays quiet when offline; U says why.
f.err = errors.New("no network")
d.keys("U")
if !strings.Contains(d.screen(), "couldn't check for a newer lazysecrets: no network") {
t.Errorf("no error:\n%s", d.screen())
}
// A development build isn't offered releases.
dev := newDriverOpts(t, Options{Version: "dev-2ba762c", Updater: (&fakeUpdater{latest: v020}).updater(), UpdateCheck: true, Store: mem})
if strings.Contains(dev.screen(), "↑") {
t.Errorf("dev build offered an update:\n%s", dev.screen())
}
dev.keys("U")
if !strings.Contains(dev.screen(), "development build") {
t.Errorf("U on a dev build:\n%s", dev.screen())
}
}
+40
View File
@@ -0,0 +1,40 @@
package app
import (
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// Usage: revealing, copying, opening in the browser, sharing and jumping
// to an item count as using it. The store keeps when each item was last
// used and how often, for the Recent section of the jump list. The config's
// history: false turns this off.
// used counts a use of the remembered item key, keeping its context.
func (m *Model) used(key string) {
if !m.history {
return
}
_ = m.store.Use(key, m.now(), func(*store.Node) {}) // best effort: never in the way
}
// usedItem counts a use of an item, recording its context.
func (m *Model) usedItem(key, name string, path []string, src provider.Source) {
if !m.history {
return
}
_ = m.store.Use(key, m.now(), withContext(name, path, src))
}
func (m *Model) usedSource(s provider.Source) {
m.usedItem(sourceKey(s), s.Name, nil, s)
}
func (m *Model) usedContainer(c provider.Container) {
m.usedItem(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
}
func (m *Model) usedObject(o provider.Object) {
c := o.Container
m.usedItem(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
}
+47
View File
@@ -0,0 +1,47 @@
package app
import (
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
func TestRecentScope(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "j", "space") // reveal api-key
d.keys("j", "s") // share legacy-token: a use too
d.keys("2", "j", "f") // star kv-shared-prod
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key"
if n := d.m.store.Node(key); n.Uses != 1 || n.LastUsed.IsZero() || n.Name != "api-key" {
t.Fatalf("reveal not counted: %+v", n)
}
d.keys("R")
var recent []string
for _, o := range d.m.search.shown {
recent = append(recent, ansiStrip(o.label))
}
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent)
}
d.keys("enter") // jump to legacy-token
if _, _, s := d.selected(); s != "legacy-token" {
t.Errorf("jumped to %q", s)
}
d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
}
}
func TestNoHistory(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
d := newDriverOpts(t, Options{Store: mem, NoHistory: true})
d.keys("3", "j", "space", "y")
if len(mem.Nodes()) != 0 {
t.Errorf("recorded with history off: %v", mem.Nodes())
}
}
+45
View File
@@ -0,0 +1,45 @@
// Package icons maps backends to Nerd Font glyphs. They are on by default
// and can be turned off, since they render as boxes without a Nerd Font
// (v3+) installed.
package icons
import "git.kebcloud.no/keb/lazysecrets/internal/provider"
type Set struct {
Azure string
Databricks string
Repo string
Encrypted string // an encrypted file (SOPS)
PlainFile string // a plain text secrets file (.env)
}
// None renders no icons.
var None = Set{}
// NerdFont uses Nerd Fonts v3 Material Design glyphs. There is no Databricks
// logo glyph, so it uses stacked layers, which resembles it.
var NerdFont = Set{
Azure: "\U000F0805", // md-microsoft_azure
Databricks: "\U000F0F58", // md-layers_triple
Repo: "\U000F02A2", // md-git
Encrypted: "\U000F033E", // md-lock
PlainFile: "\U000F0214", // md-file
}
// Prefix returns the icon for kind followed by a space, or "" if the set has
// no icon for it.
func (s Set) Prefix(kind provider.Kind) string {
var icon string
switch kind {
case provider.KindAzure:
icon = s.Azure
case provider.KindDatabricks:
icon = s.Databricks
case provider.KindRepo:
icon = s.Repo
}
if icon == "" {
return ""
}
return icon + " "
}
+376
View File
@@ -0,0 +1,376 @@
// Package keys defines the keymap: named actions in contexts, their
// default keys, and overrides from the config:
//
// keybinding:
// lists:
// star: [f]
// versions: [] # unbound
// filter:
// star: [ctrl+f]
//
// An action set in the config gets exactly the keys given, replacing its
// defaults; an empty list unbinds it. Everything that shows a key (the
// help bar, ?, footers, hints) reads it from here.
package keys
import (
"fmt"
"slices"
"sort"
"strings"
"charm.land/bubbles/v2/key"
)
// Contexts group the actions by where they apply. The search popup takes
// search's actions and popup's.
const (
Lists = "lists" // the panes, when not typing
Filter = "filter" // typing a filter in a pane
Menu = "menu" // popups without a text field
Popup = "popup" // popups with a text field: favourites, help, search, notes, open link
Search = "search" // Go to's own actions while typing
GoTo = "goto" // Go to while browsing (no text field active)
)
type Map struct {
// lists
Up, Down, Top, Bottom key.Binding
NextPane, PrevPane key.Binding
Pane1, Pane2, Pane3, Pane4 key.Binding
NextTab, PrevTab key.Binding
Enter, Back, Filter key.Binding
Reveal, Copy, CopyRef, Open key.Binding
Versions, Favourite, Favourites key.Binding
Recent key.Binding
Note, Share, ShareMenu, OpenRef key.Binding
Search, SourcesView, Commands key.Binding
SplitLeft, SplitRight, SplitReset key.Binding
Taller, Shorter, ZoomIn, ZoomOut key.Binding
Login, Refresh, Help, Quit key.Binding
Update key.Binding
// filter
AcceptFilter, ClearFilter key.Binding
FilterUp, FilterDown key.Binding
FilterCopy, FilterReveal, FilterStar key.Binding
// menu
MenuUp, MenuDown, MenuSelect, MenuClose key.Binding
// popup
PopupUp, PopupDown, PopupSelect, PopupClose, PopupStar key.Binding
// search
SearchSources, SearchResults, SearchSwitch key.Binding
SearchLoad, SearchLoadAll key.Binding
// goto
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding
GoSources, GoResults, GoSwitch key.Binding
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
GoSelect, GoClose key.Binding
}
// Action is a binding with its name in the config and what it does.
type Action struct {
Context, Name string
Binding *key.Binding
Desc string // short, for the help bar
Group string // section in the ? menu
Tip string // a sentence or two for the ? menu
}
// Actions lists every action, in the ? menu's order.
func (m *Map) Actions() []Action {
a := func(ctx, name string, b *key.Binding, desc, group, tip string) Action {
return Action{ctx, name, b, desc, group, tip}
}
return []Action{
a(Lists, "up", &m.Up, "up", "move", "Move up in the focused list, or scroll the detail pane."),
a(Lists, "down", &m.Down, "down", "move", "Move down in the focused list, or scroll the detail pane."),
a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."),
a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."),
a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."),
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane."),
a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."),
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."),
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
a(Lists, "note", &m.Note, "note", "lists", "Write a short note on the selected subscription, vault or secret, shown on the right of its row. Filtering matches notes too."),
a(Lists, "reveal", &m.Reveal, "reveal/hide", "secrets", "Show or hide the selected value. For keys and certificates: the public key or certificate."),
a(Lists, "copyValue", &m.Copy, "copy value", "secrets", "Copy the selected value without showing it. It is cleared from the clipboard after a countdown."),
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
a(Lists, "dividerRight", &m.SplitRight, "divider right", "layout", "Move the divider between the left panes and the detail pane to the right."),
a(Lists, "taller", &m.Taller, "taller", "layout", "Make the focused left pane taller."),
a(Lists, "shorter", &m.Shorter, "shorter", "layout", "Make the focused left pane shorter."),
a(Lists, "zoomIn", &m.ZoomIn, "zoom", "layout", "Zoom the focused pane: normal, half screen, full screen."),
a(Lists, "zoomOut", &m.ZoomOut, "unzoom", "layout", "Zoom back out."),
a(Lists, "resetLayout", &m.SplitReset, "reset layout", "layout", "Reset the divider, pane heights and zoom."),
a(Lists, "refresh", &m.Refresh, "refresh", "other", "Reload the focused pane, bypassing the cache."),
a(Lists, "login", &m.Login, "sign in", "other", "Sign in again (az login or databricks auth login) when a login has expired."),
a(Lists, "update", &m.Update, "update", "other", "Check for a newer lazysecrets: its changelog, and update to it."),
a(Lists, "help", &m.Help, "help", "other", "Show this list. Also works in Go to while browsing, and in menus, showing their keys first."),
a(Lists, "quit", &m.Quit, "quit", "other", "Quit lazysecrets. ctrl+c always quits too."),
a(Filter, "down", &m.FilterDown, "down", "while filtering", "Move down while typing a filter."),
a(Filter, "up", &m.FilterUp, "up", "while filtering", "Move up while typing a filter."),
a(Filter, "copyValue", &m.FilterCopy, "copy value", "while filtering", "Copy the selected value while typing a filter."),
a(Filter, "reveal", &m.FilterReveal, "reveal/hide", "while filtering", "Reveal or hide the selected value while typing a filter."),
a(Filter, "star", &m.FilterStar, "star", "while filtering", "Star or unstar the selected item while typing a filter."),
a(Filter, "accept", &m.AcceptFilter, "apply filter", "while filtering", "Stop typing and keep the filter."),
a(Filter, "clear", &m.ClearFilter, "clear filter", "while filtering", "Clear the filter."),
a(Menu, "down", &m.MenuDown, "down", "menus", "Move down in a menu."),
a(Menu, "up", &m.MenuUp, "up", "menus", "Move up in a menu."),
a(Menu, "select", &m.MenuSelect, "select", "menus", "Pick the selected entry. Each entry's letter picks it directly."),
a(Menu, "close", &m.MenuClose, "dismiss", "menus", "Close the menu."),
a(Popup, "down", &m.PopupDown, "down", "popups", "Move down in a popup with a text field (favourites, help, search)."),
a(Popup, "up", &m.PopupUp, "up", "popups", "Move up in a popup with a text field."),
a(Popup, "select", &m.PopupSelect, "go", "popups", "Go to the selected entry, or confirm what you typed."),
a(Popup, "close", &m.PopupClose, "close", "popups", "Close the popup."),
a(Popup, "star", &m.PopupStar, "star", "popups", "Star or unstar the selected entry in Go to. It stays where it is until Go to opens again."),
a(Search, "sources", &m.SearchSources, "sources", "search", "Move to the sources list."),
a(Search, "results", &m.SearchResults, "results", "search", "Move to the results list."),
a(Search, "switchList", &m.SearchSwitch, "switch list", "search", "Switch between the sources and the results."),
a(Search, "load", &m.SearchLoad, "load selected", "search", "Load (or reload) the selected source's vaults and secrets, or the selected vault's secrets, in the background."),
a(Search, "loadAll", &m.SearchLoadAll, "load all", "search", "Load every source's vaults and secrets in the background, to search them all."),
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."),
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
a(GoTo, "switchList", &m.GoSwitch, "switch list", "go to", "Switch between the sources and the results (Search tab)."),
a(GoTo, "star", &m.GoStar, "star", "go to", "Star or unstar the selected entry. It stays where it is until Go to opens again."),
a(GoTo, "load", &m.GoLoad, "load selected", "go to", "Load (or reload) the selected source or vault in the background (Search tab)."),
a(GoTo, "loadAll", &m.GoLoadAll, "load all", "go to", "Load everything in the background, to search it all (Search tab)."),
a(GoTo, "filter", &m.GoFilter, "filter", "go to", "Start typing to filter the list."),
a(GoTo, "select", &m.GoSelect, "go", "go to", "Go to the selected entry."),
a(GoTo, "close", &m.GoClose, "close", "go to", "Close Go to."),
}
}
// defaults follow one rule: a letter in the lists, ctrl+ the same letter
// where you type.
var defaults = map[string][]string{
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"},
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"},
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
"lists.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
"lists.versions": {"v"}, "lists.openInBrowser": {"o"},
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
"lists.refresh": {"r"}, "lists.login": {"L"}, "lists.update": {"U"}, "lists.help": {"?"}, "lists.quit": {"q"},
"filter.down": {"ctrl+j", "ctrl+n", "down"}, "filter.up": {"ctrl+k", "ctrl+p", "up"},
"filter.copyValue": {"ctrl+y"}, "filter.reveal": {"ctrl+r"}, "filter.star": {"ctrl+f"},
"filter.accept": {"enter"}, "filter.clear": {"esc"},
"menu.down": {"j", "down", "ctrl+j", "ctrl+n"}, "menu.up": {"k", "up", "ctrl+k", "ctrl+p"},
"menu.select": {"enter"}, "menu.close": {"esc"},
"popup.down": {"ctrl+j", "ctrl+n", "down"}, "popup.up": {"ctrl+k", "ctrl+p", "up"},
"popup.select": {"enter"}, "popup.close": {"esc"}, "popup.star": {"ctrl+f"},
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
"goto.select": {"enter"}, "goto.close": {"esc", "q"},
}
// Default is the keymap without overrides.
func Default() Map {
var m Map
for _, a := range m.Actions() {
*a.Binding = bind(defaults[a.Context+"."+a.Name], a.Desc)
}
m.Login.SetEnabled(false) // shown and active only once a login expires
return m
}
func bind(keys []string, desc string) key.Binding {
if len(keys) == 0 {
return key.NewBinding(key.WithDisabled())
}
return key.NewBinding(key.WithKeys(keys...), key.WithHelp(Label(keys), desc))
}
// Label shows keys in help: the first two, arrows as arrows, a shared
// ctrl+ written once ("k/↑", "ctrl+j/n").
func Label(keys []string) string {
pretty := strings.NewReplacer("left", "←", "right", "→", "up", "↑", "down", "↓", "backspace", "⌫")
var out []string
for i, k := range keys[:min(2, len(keys))] {
k = pretty.Replace(k)
if rest, ok := strings.CutPrefix(k, "ctrl+"); ok && i > 0 && strings.HasPrefix(out[i-1], "ctrl+") {
k = rest
}
out = append(out, k)
}
return strings.Join(out, "/")
}
// Name is the key to mention in a hint ("space to reveal"): the first one
// bound, or "" if the action is unbound.
func Name(b key.Binding) string {
if ks := b.Keys(); len(ks) > 0 {
return Label(ks[:1])
}
return ""
}
// Apply overrides the defaults from the config: each action given gets
// exactly the keys listed; none unbinds it. Unknown contexts or actions,
// and a key bound to two actions that apply together, are errors.
func (m *Map) Apply(cfg map[string]map[string][]string) error {
byName := map[string]Action{}
contexts := map[string]bool{}
for _, a := range m.Actions() {
byName[a.Context+"."+a.Name] = a
contexts[a.Context] = true
}
for _, ctx := range sortedKeys(cfg) {
if !contexts[ctx] {
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
}
for _, name := range sortedKeys(cfg[ctx]) {
a, ok := byName[ctx+"."+name]
if !ok {
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
}
*a.Binding = bind(cfg[ctx][name], a.Desc)
}
}
m.Login.SetEnabled(false)
return m.check()
}
func (m *Map) names(ctx string) []string {
var out []string
for _, a := range m.Actions() {
if a.Context == ctx {
out = append(out, a.Name)
}
}
sort.Strings(out)
return out
}
func sortedKeys[V any](set map[string]V) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// check finds a key bound to two actions that apply together: within a
// context, and in the search popup (search and popup).
func (m *Map) check() error {
for _, group := range [][]string{{Lists}, {Filter}, {Menu}, {Popup}, {Search, Popup}, {GoTo}} {
seen := map[string]string{}
for _, a := range m.Actions() {
if !slices.Contains(group, a.Context) {
continue
}
name := a.Context + "." + a.Name
for _, k := range a.Binding.Keys() {
if other, ok := seen[k]; ok && other != name {
return fmt.Errorf("keybinding: %q is bound to both %s and %s", k, other, name)
}
seen[k] = name
}
}
}
return nil
}
// Section is a titled group of actions for the ? menu.
type Section struct {
Title string
Entries []Action
}
// Sections groups the actions for the ? menu.
func (m *Map) Sections() []Section {
var out []Section
for _, a := range m.Actions() {
if n := len(out); n == 0 || out[n-1].Title != a.Group {
out = append(out, Section{Title: a.Group})
}
out[len(out)-1].Entries = append(out[len(out)-1].Entries, a)
}
return out
}
// FilterHelp is shown while typing a filter.
type FilterHelp struct{ Map }
func (f FilterHelp) ShortHelp() []key.Binding {
return []key.Binding{f.FilterDown, f.FilterUp, f.FilterCopy, f.FilterReveal, f.FilterStar, f.AcceptFilter, f.ClearFilter}
}
func (f FilterHelp) FullHelp() [][]key.Binding { return [][]key.Binding{f.ShortHelp()} }
// YAML is the keymap as a config section, every action with its keys and
// what it does, to copy into the config and edit.
func (m *Map) YAML() string {
var b strings.Builder
b.WriteString("keybinding:\n")
ctx := ""
for _, a := range m.Actions() {
if a.Context != ctx {
ctx = a.Context
fmt.Fprintf(&b, " %s:\n", ctx)
}
quoted := make([]string, len(a.Binding.Keys()))
for i, k := range a.Binding.Keys() {
quoted[i] = fmt.Sprintf("%q", k)
}
fmt.Fprintf(&b, " %-15s [%s] # %s\n", a.Name+":", strings.Join(quoted, ", "), a.Desc)
}
return b.String()
}
// CheckExtra reports a key some other binding in the lists takes, for keys
// added outside the keymap (custom commands). name says whose key it is.
func (m *Map) CheckExtra(k, name string) error {
for _, a := range m.Actions() {
if a.Context == Lists && slices.Contains(a.Binding.Keys(), k) {
return fmt.Errorf("%s: %q is bound to lists.%s", name, k, a.Name)
}
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package keys
import (
"strings"
"testing"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
)
func press(s string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: rune(s[0]), Text: s} }
func TestDefaultsHaveNoConflicts(t *testing.T) {
m := Default()
if err := m.check(); err != nil {
t.Fatal(err)
}
for _, a := range m.Actions() {
if len(a.Binding.Keys()) == 0 {
t.Errorf("%s.%s has no default keys", a.Context, a.Name)
}
}
}
func TestApply(t *testing.T) {
m := Default()
err := m.Apply(map[string]map[string][]string{
"lists": {"star": {"z"}, "versions": {}},
"filter": {"star": {"ctrl+x"}},
})
if err != nil {
t.Fatal(err)
}
if !key.Matches(press("z"), m.Favourite) || key.Matches(press("f"), m.Favourite) {
t.Error("star not rebound from f to z")
}
if m.Favourite.Help().Key != "z" {
t.Errorf("help shows %q", m.Favourite.Help().Key)
}
if m.Versions.Enabled() || Name(m.Versions) != "" {
t.Error("versions not unbound")
}
if Name(m.FilterStar) != "ctrl+x" || Name(m.PopupStar) != "ctrl+f" {
t.Errorf("filter star %q, popup star %q", Name(m.FilterStar), Name(m.PopupStar))
}
}
func TestApplyErrors(t *testing.T) {
for _, c := range []struct {
cfg map[string]map[string][]string
want string
}{
{map[string]map[string][]string{"panes": {"star": {"x"}}}, `unknown context "panes"`},
{map[string]map[string][]string{"lists": {"starr": {"x"}}}, `unknown action "starr"`},
{map[string]map[string][]string{"lists": {"star": {"s"}}}, `"s" is bound to both`}, // share
{map[string]map[string][]string{"search": {"load": {"ctrl+f"}}}, `"ctrl+f" is bound to both`}, // popup star
} {
m := Default()
if err := m.Apply(c.cfg); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%v: %v, want %q", c.cfg, err, c.want)
}
}
// The same key in contexts that never apply together is fine.
m := Default()
if err := m.Apply(map[string]map[string][]string{"filter": {"star": {"ctrl+r"}, "reveal": {"ctrl+e"}}}); err != nil {
t.Errorf("filter ctrl+r and search ctrl+r clash: %v", err)
}
}
func TestLabel(t *testing.T) {
for keys, want := range map[string]string{
"k up": "k/↑",
"ctrl+j ctrl+n down": "ctrl+j/n",
"space": "space",
"ctrl+left <": "ctrl+←/<",
} {
if got := Label(strings.Fields(keys)); got != want {
t.Errorf("Label(%s) = %q, want %q", keys, got, want)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package panes contains the lazygit-style bordered panes.
package panes
import (
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// Box draws a rounded border of exactly w×h cells with the title embedded in
// the top edge and an optional footer in the bottom edge. The title is
// rendered as given, so callers style it (see TitleStyle). Body lines are
// truncated or padded to fit; extra lines are dropped.
func Box(st styles.Styles, title, footer string, body []string, w, h int, focused bool) string {
return BoxCounted(st, title, footer, "", body, w, h, focused)
}
// BoxCounted is Box with a counter like "3 of 12" at the right end of the
// bottom edge, where lazygit puts it.
func BoxCounted(st styles.Styles, title, footer, counter string, body []string, w, h int, focused bool) string {
if w < 4 || h < 2 {
return ""
}
border := st.BorderBlurred
if focused {
border = st.BorderFocused
}
plain := func(s ...string) string { return strings.Join(s, "") }
inner := w - 2
edge := func(left, right, label string, styled func(...string) string) string {
if label == "" {
return border.Render(left + strings.Repeat("─", inner) + right)
}
label = ansi.Truncate(" "+label+" ", inner-1, "…")
fill := inner - 1 - ansi.StringWidth(label)
return border.Render(left+"─") + styled(label) + border.Render(strings.Repeat("─", fill)+right)
}
var b strings.Builder
b.WriteString(edge("╭", "╮", title, plain))
side := border.Render("│")
for i := range h - 2 {
line := ""
if i < len(body) {
line = ansi.Truncate(body[i], inner, "…")
}
b.WriteString("\n" + side + line + strings.Repeat(" ", inner-ansi.StringWidth(line)) + side)
}
bottom := edge("╰", "╯", footer, border.Render)
if counter != "" {
// Overwrite the end of the bottom edge: "… 3 of 12 ─╯".
label := " " + counter + " "
if keep := w - 2 - ansi.StringWidth(label); keep > 2 {
bottom = ansi.Truncate(bottom, keep, "") + border.Render(label+"─╯")
}
}
b.WriteString("\n" + bottom)
return b.String()
}
// TitleStyle returns the style for a pane title.
func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
if focused {
return st.TitleFocused
}
return st.TitleBlurred
}
+374
View File
@@ -0,0 +1,374 @@
package panes
import (
"cmp"
"fmt"
"maps"
"slices"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
type ValueState int
const (
ValueHidden ValueState = iota
ValueLoading
ValueShown
ValueFailed
)
// Detail shows metadata for the selected secret (or container) and, on
// request, its value.
type Detail struct {
Now func() time.Time
Icons icons.Set
// RevealKey and VersionsKey are the keys hints mention ("space to
// reveal"); a hint is left out when its key is "" (unbound).
RevealKey, VersionsKey string
secret *provider.Object
container *provider.Container
state ValueState
value provider.SecretValue
err error
hidesIn string // when a shown value hides by itself, if it does
describing bool
describeErr error
versions []provider.Object // newest first; nil while unknown
note string // the user's note on what's shown
offset int
w, h int
}
func NewDetail(iconSet icons.Set) Detail { return Detail{Now: time.Now, Icons: iconSet} }
// ShowSecret selects a secret and forgets any previously fetched value.
func (d *Detail) ShowSecret(s provider.Object) {
d.Clear()
d.secret = &s
d.container = &s.Container
}
// ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) {
d.Clear()
d.container = &c
}
// Clear drops the selection and any fetched value.
func (d *Detail) Clear() {
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, w: d.w, h: d.h} // drops the note too
}
// SetDescribing marks extra metadata as being fetched.
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
// SetDescribed replaces the shown object's metadata, keeping the value
// state. It ignores objects other than the one shown.
func (d *Detail) SetDescribed(o provider.Object) {
if d.secret == nil || d.secret.ID != o.ID {
return
}
// Keep the container (and so the account) the object was shown with;
// value requests are routed by it.
o.Container = d.secret.Container
d.secret = &o
d.describing = false
}
// SetNote shows the user's note on the shown object or container.
func (d *Detail) SetNote(note string) { d.note = note }
// SetVersions records the shown object's versions, newest first. It
// ignores objects other than the one shown.
func (d *Detail) SetVersions(id string, versions []provider.Object) {
if d.secret != nil && d.secret.ID == id {
d.versions = versions
}
}
func (d *Detail) SetDescribeError(err error) { d.describing, d.describeErr = false, err }
func (d Detail) Secret() (provider.Object, bool) {
if d.secret == nil {
return provider.Object{}, false
}
return *d.secret, true
}
// Container is the container shown, if any.
func (d Detail) Container() (provider.Container, bool) {
if d.container == nil {
return provider.Container{}, false
}
return *d.container, true
}
// Value returns the value if it is shown.
func (d Detail) Value() (provider.SecretValue, bool) {
return d.value, d.state == ValueShown
}
func (d Detail) State() ValueState { return d.state }
func (d *Detail) SetLoading() { d.state, d.err = ValueLoading, nil }
func (d *Detail) SetValue(v provider.SecretValue) { d.state, d.value, d.err = ValueShown, v, nil }
func (d *Detail) SetValueError(err error) { d.state, d.err = ValueFailed, err }
// Hide masks the value again and drops it from memory.
func (d *Detail) Hide() {
d.state, d.value, d.err, d.hidesIn = ValueHidden, provider.SecretValue{}, nil, ""
}
// SetHidesIn shows when a revealed value hides by itself, e.g. "42s".
func (d *Detail) SetHidesIn(s string) { d.hidesIn = s }
func (d *Detail) SetSize(w, h int) {
d.w, d.h = w, h
d.Scroll(0)
}
func (d *Detail) Scroll(delta int) {
total := len(d.lines(styles.Styles{}))
d.offset = max(0, min(d.offset+delta, total-(d.h-2)))
}
func (d *Detail) ScrollTop() { d.offset = 0 }
func (d *Detail) ScrollBottom() { d.Scroll(1 << 30) }
func (d Detail) View(st styles.Styles, focused bool, spin string) string {
lines := d.lines(st)
if spin != "" && d.state == ValueLoading {
for i, l := range lines {
lines[i] = strings.Replace(l, spinnerPlaceholder, spin, 1)
}
}
rows := max(0, d.h-2)
footer := ""
if len(lines) > rows {
footer = fmt.Sprintf("%d-%d of %d", d.offset+1, min(d.offset+rows, len(lines)), len(lines))
}
title := "Details"
if d.secret != nil {
title = string(d.secret.Type)
}
return Box(st, TitleStyle(st, focused).Render(title), footer, lines[min(d.offset, len(lines)):], d.w, d.h, focused)
}
const spinnerPlaceholder = "\x00spin\x00"
// lines renders the body, wrapped to the pane width.
func (d Detail) lines(st styles.Styles) []string {
inner := max(1, d.w-3)
var out []string
field := func(label, value string) {
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
indent := strings.Repeat(" ", 12)
wrapped := strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")
out = append(out, prefix+wrapped[0])
for _, w := range wrapped[1:] {
out = append(out, indent+w)
}
}
muted := st.Muted.Render
if d.container == nil {
return []string{muted(" Select a vault or scope to browse its secrets.")}
}
c := *d.container
containerLabel := map[provider.Kind]string{provider.KindDatabricks: "scope", provider.KindRepo: "file"}[c.Source.Kind]
if containerLabel == "" {
containerLabel = "vault"
}
if d.secret == nil {
field(containerLabel, c.Name)
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" {
field("", muted(c.Detail))
}
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
out = append(out, "", muted(" Select a secret to see its details."))
return out
}
s := *d.secret
field("name", s.Name)
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
field(containerLabel, c.Name)
if c.Source.Kind != provider.KindAzure && c.Detail != "" {
field("", muted(c.Detail))
}
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
if s.Version != "" {
field("version", s.Version)
}
if s.Enabled != nil {
if *s.Enabled {
field("enabled", "yes")
} else {
field("enabled", st.Error.Render("no"))
}
}
if !s.Updated.IsZero() {
field("updated", formatTime(s.Updated))
}
if !s.Expires.IsZero() {
exp := formatTime(s.Expires)
if s.Expires.Before(d.Now()) {
exp = st.Error.Render(exp + " (expired)")
}
field("expires", exp)
}
if s.ContentType != "" {
field("content", s.ContentType)
}
for _, a := range s.Attrs {
field(a.Label, a.Value)
}
if len(s.Tags) > 0 {
var tags []string
for _, k := range slices.Sorted(maps.Keys(s.Tags)) {
tags = append(tags, k+"="+s.Tags[k])
}
field("tags", strings.Join(tags, ", "))
}
if s.Reference != "" {
field("ref", muted(s.Reference))
}
switch {
case d.describing:
out = append(out, muted(" loading details…"))
case d.describeErr != nil:
field("details", st.Error.Render(d.describeErr.Error()))
}
// Secrets are masked; keys and certificates only expose public material.
keyHint := func(k, what string) string {
if k == "" {
return ""
}
return "(" + k + " to " + what + ")"
}
label, hidden := "value", st.Masked.Render("••••••••")+muted(" "+keyHint(d.RevealKey, "reveal"))
switch s.Type {
case provider.TypeKey:
label, hidden = "public", muted(keyHint(d.RevealKey, "show public key"))
case provider.TypeCertificate:
label, hidden = "cert", muted(keyHint(d.RevealKey, "show certificate"))
}
out = append(out, "")
switch d.state {
case ValueHidden:
field(label, hidden)
case ValueLoading:
field(label, spinnerPlaceholder+" fetching…")
case ValueFailed:
// Errors can run over several lines (sops lists every key it
// tried): all of them, each wrapped at spaces and styled alone,
// so lines aren't padded to the longest.
for i, line := range strings.Split(strings.TrimRight(d.err.Error(), "\n"), "\n") {
// Keep a line's own indent, on each part it wraps into.
text := strings.TrimLeft(line, " ")
lead := strings.Repeat(" ", len(line)-len(text))
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
w = lead + w
if i == 0 && j == 0 {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
} else {
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
}
}
}
case ValueShown:
var hints []string
if d.RevealKey != "" {
hints = append(hints, d.RevealKey+" to hide")
}
if d.hidesIn != "" {
hints = append(hints, "hides in "+d.hidesIn)
}
line := " " + st.Label.Render(label)
if len(hints) > 0 {
line += muted(" (" + strings.Join(hints, " · ") + ")")
}
out = append(out, line)
for _, line := range strings.Split(d.value.Reveal(), "\n") {
for _, w := range strings.Split(ansi.Hardwrap(line, inner, true), "\n") {
out = append(out, " "+st.Value.Render(w))
}
}
}
return append(out, d.versionLines(st)...)
}
// maxVersionLines caps the version history shown in the detail pane; the
// versions view (v) shows them all.
const maxVersionLines = 10
func (d Detail) versionLines(st styles.Styles) []string {
if len(d.versions) == 0 {
return nil
}
muted := st.Muted.Render
head := fmt.Sprintf("%d", len(d.versions))
if len(d.versions) > 1 {
head = st.Notice.Render(head)
if d.VersionsKey != "" {
head += muted(" · " + d.VersionsKey + " to browse")
}
}
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
current := d.versions[0].Version
for i, v := range d.versions {
if i == maxVersionLines {
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
break
}
marker := " "
if d.secret.Version != "" && v.Version == d.secret.Version {
marker = "▸ " // the version shown above
}
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
}
return out
}
// VersionLine draws a version as "id date state".
func VersionLine(st styles.Styles, v provider.Object, now time.Time, current string) string {
id := v.Version
if len(id) > 8 {
id = id[:8]
}
line := fmt.Sprintf("%-8s %s", id, v.Updated.Local().Format("2006-01-02"))
switch {
case v.Enabled != nil && !*v.Enabled:
return st.Muted.Render(line + " disabled")
case !v.Expires.IsZero() && v.Expires.Before(now):
return st.Muted.Render(line + " expired")
case v.Version == current:
return line + st.Notice.Render(" current")
}
return line
}
func formatTime(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }
+53
View File
@@ -0,0 +1,53 @@
package panes
import (
"image/color"
"regexp"
"strings"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
// sgr matches SGR escape sequences.
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
// Highlight draws a cursor row: line, padded to width, on style's
// background, keeping the colours line already has (a provider's colour,
// a dimmed note), like LazyVim's and lazygit's selected lines; bold
// carries over too. A reverse style is applied to the plain text instead,
// since reversing coloured text would scramble it.
func Highlight(line string, style lipgloss.Style, width int) string {
pad := strings.Repeat(" ", max(0, width-ansi.StringWidth(line)))
if style.GetReverse() {
return style.Render(ansi.Strip(line) + pad)
}
var s ansi.Style
if bg := style.GetBackground(); !isNoColor(bg) { // never nil: NoColor when unset
s = s.BackgroundColor(bg)
}
if style.GetBold() {
s = s.Bold()
}
if len(s) == 0 {
return line + pad
}
on := s.String()
// Every reset in line (a bare reset, one starting with 0, or a
// background reset) would end the highlight: put it back after each.
line = sgr.ReplaceAllStringFunc(line, func(seq string) string {
params := sgr.FindStringSubmatch(seq)[1]
for _, p := range strings.Split(params, ";") {
if p == "" || p == "0" || p == "49" {
return seq + on
}
}
return seq
})
return on + line + pad + ansi.ResetStyle
}
func isNoColor(c color.Color) bool {
_, ok := c.(lipgloss.NoColor)
return ok
}
+388
View File
@@ -0,0 +1,388 @@
package panes
import (
"fmt"
"strings"
"unicode/utf8"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// Item adapts a value for display in a List.
type Item[T any] struct {
ID func(T) string // stable identity, used to detect selection changes
Filter func(T) string // text matched by the filter, and highlighted
// FilterExtra, if set, is matched too but not shown highlighted, for
// text the row doesn't show (e.g. the provider of a grouped source).
FilterExtra func(T) string
Render func(st styles.Styles, v T, width int) string
}
// List is a scrollable, filterable list pane with loading and error states.
type List[T any] struct {
Title string
// Tabs, when there are several, are shown after the title; Tab is the
// active one.
Tabs []string
Tab int
Empty string // shown when loaded with no items
Loading bool
Err error
item Item[T]
items []T
visible []int // indices into items that match the filter, best first
hits [][]int // per visible item: matched rune indices in its Filter text
cursor int // index into visible
offset int // first visible row
w, h int // outer size including border
input textinput.Model
filtering bool
}
func NewList[T any](title string, item Item[T]) List[T] {
in := textinput.New()
in.Prompt = "/"
return List[T]{Title: title, Empty: "nothing here", item: item, input: in}
}
// SetRender changes how items are drawn, e.g. when the pane switches to
// showing versions.
func (l *List[T]) SetRender(render func(st styles.Styles, v T, width int) string) {
l.item.Render = render
}
// SetItems replaces the contents, keeping the cursor on the same item if it
// is still present.
func (l *List[T]) SetItems(items []T) {
prev := l.SelectedID()
l.items = items
l.Loading = false
l.Err = nil
l.applyFilter(prev)
}
// Reset clears items, error, filter and cursor.
func (l *List[T]) Reset() {
l.items, l.visible = nil, nil
l.cursor, l.offset = 0, 0
l.Loading, l.Err = false, nil
l.filtering = false
l.input.Reset()
}
func (l *List[T]) SetError(err error) {
l.Reset()
l.Err = err
}
// Refresh marks the pane as loading while keeping the current items and
// selection on screen until SetItems replaces them.
func (l *List[T]) Refresh() {
l.Loading = true
l.Err = nil
}
func (l *List[T]) SetLoading() {
l.Reset()
l.Loading = true
}
func (l List[T]) Selected() (T, bool) {
if l.cursor < 0 || l.cursor >= len(l.visible) {
var zero T
return zero, false
}
return l.items[l.visible[l.cursor]], true
}
// SelectedID is "" when nothing is selected.
func (l List[T]) SelectedID() string {
v, ok := l.Selected()
if !ok {
return ""
}
return l.item.ID(v)
}
func (l List[T]) Len() int { return len(l.visible) }
// Width is the pane's outer width.
func (l List[T]) Width() int { return l.w }
// Items returns all items, ignoring the filter.
func (l List[T]) Items() []T { return l.items }
// Select moves the cursor to the item with the given ID, if visible, and
// reports whether it was.
func (l *List[T]) Select(id string) bool {
return l.SelectWhere(func(v T) bool { return l.item.ID(v) == id })
}
// SelectWhere moves the cursor to the first visible item matching f.
func (l *List[T]) SelectWhere(f func(T) bool) bool {
for i, idx := range l.visible {
if f(l.items[idx]) {
l.cursor = i
l.scroll()
return true
}
}
return false
}
func (l *List[T]) Move(delta int) {
l.cursor = max(0, min(l.cursor+delta, len(l.visible)-1))
l.scroll()
}
func (l *List[T]) Top() { l.Move(-len(l.visible)) }
func (l *List[T]) Bottom() { l.Move(len(l.visible)) }
// SetSize sets the outer size of the pane, including its border.
func (l *List[T]) SetSize(w, h int) {
l.w, l.h = w, h
l.input.SetWidth(max(1, w-4))
l.scroll()
}
// rows is the number of item rows that fit in the pane.
func (l List[T]) rows() int {
r := l.h - 2
if l.filtering {
r--
}
return max(0, r)
}
// Filtering reports whether the filter input has focus.
func (l List[T]) Filtering() bool { return l.filtering }
// HasFilter reports whether a filter query is applied.
func (l List[T]) HasFilter() bool { return l.input.Value() != "" }
func (l *List[T]) StartFilter() tea.Cmd {
l.filtering = true
l.scroll()
return l.input.Focus()
}
// AcceptFilter leaves filter mode, keeping the query applied.
func (l *List[T]) AcceptFilter() {
l.filtering = false
l.input.Blur()
}
// ClearFilter leaves filter mode and removes the query.
func (l *List[T]) ClearFilter() {
l.filtering = false
l.input.Blur()
prev := l.SelectedID()
l.input.Reset()
l.applyFilter(prev)
}
// UpdateFilter forwards a message to the filter input and re-filters.
func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd {
var cmd tea.Cmd
prev := l.SelectedID()
l.input, cmd = l.input.Update(msg)
l.applyFilter(prev)
return cmd
}
// applyFilter recomputes the visible items and puts the cursor back on the
// item with ID keep if it is still visible.
func (l *List[T]) applyFilter(keep string) {
l.visible, l.hits = l.visible[:0], nil
if q := l.input.Value(); q == "" {
for i := range l.items {
l.visible = append(l.visible, i)
}
} else {
// The row's own text is the name; FilterExtra (a note, a provider)
// is context, matched too but ranked lower.
items := make([]match.Item, len(l.items))
for i, v := range l.items {
text := l.item.Filter(v)
items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
if l.item.FilterExtra != nil {
if extra := l.item.FilterExtra(v); extra != "" {
items[i].Text += " " + extra
}
}
}
for _, h := range match.Rank(match.Parse(q), items) {
var hits []int // the ones in the text the row shows
for _, p := range h.Pos {
if p < items[h.Index].NameEnd {
hits = append(hits, p)
}
}
l.visible = append(l.visible, h.Index)
l.hits = append(l.hits, hits)
}
}
l.cursor = max(0, min(l.cursor, len(l.visible)-1))
if keep != "" {
l.Select(keep)
}
l.scroll()
}
// MarkMatches styles the characters at hits (rune indexes in the text
// shown, without styling) in a styled string, keeping its own colours.
func MarkMatches(styled string, hits []int, style lipgloss.Style) string {
if len(hits) == 0 {
return styled
}
return lipgloss.StyleRanges(styled, matchRanges(ansi.Strip(styled), hits, style)...)
}
// matchRanges turns matched rune indexes in plain into ranges of screen
// cells, merging neighbours, for lipgloss.StyleRanges.
func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Range {
runes := []rune(plain)
cell := func(i int) int { return ansi.StringWidth(string(runes[:min(i, len(runes))])) }
var out []lipgloss.Range
for i := 0; i < len(hits); {
j := i
for j+1 < len(hits) && hits[j+1] == hits[j]+1 {
j++
}
if hits[i] < len(runes) {
out = append(out, lipgloss.NewRange(cell(hits[i]), cell(hits[j]+1), style))
}
i = j + 1
}
return out
}
// RuneIndexes converts byte offsets in s to rune offsets.
func RuneIndexes(s string, bytes []int) []int {
out := make([]int, 0, len(bytes))
for _, b := range bytes {
out = append(out, utf8.RuneCountInString(s[:b]))
}
return out
}
// highlight returns the rune indexes to highlight in a row's plain text:
// the filter hits, shifted to where the Filter text appears in the row.
// It returns nil if the row doesn't contain the Filter text verbatim.
func (l List[T]) highlight(i int, plain string) []int {
if i >= len(l.hits) {
return nil
}
off := strings.Index(plain, l.item.Filter(l.items[l.visible[i]]))
if off < 0 {
return nil
}
shift := utf8.RuneCountInString(plain[:off])
out := make([]int, len(l.hits[i]))
for j, h := range l.hits[i] {
out[j] = h + shift
}
return out
}
// View renders the pane at the size given to SetSize. spin is the current
// spinner frame.
func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
rows := l.rows()
inner := max(0, l.w-2)
ts := TitleStyle(st, focused)
title := ts.Render(l.Title)
if len(l.Tabs) > 1 {
for i, tab := range l.Tabs {
sep := " - "
if i == 0 {
sep = " "
}
if i == l.Tab {
title += ts.Render(sep) + ts.Underline(true).Render(tab)
} else {
title += ts.Render(sep) + st.Muted.Render(tab)
}
}
}
if q := l.input.Value(); q != "" && !l.filtering {
title += ts.Render(" /" + q)
}
if l.Loading && len(l.items) > 0 {
title += ts.Render(" " + spin)
}
var body []string
if l.filtering {
body = append(body, l.input.View())
}
switch {
case l.Loading && len(l.items) == 0:
body = append(body, " "+spin+" loading…")
case l.Err != nil:
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") {
body = append(body, st.Error.Render(" "+line))
}
case len(l.visible) == 0 && len(l.items) > 0:
body = append(body, st.Muted.Render(" no matches"))
case len(l.visible) == 0:
body = append(body, st.Muted.Render(" "+l.Empty))
default:
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ {
line := l.item.Render(st, l.items[l.visible[i]], inner-1)
plain := ansi.Strip(line)
hits := l.highlight(i, plain)
if i == l.cursor {
base := st.SelectedBlurred
if focused {
base = st.Selected
}
text := " " + line
if len(hits) > 0 {
for j := range hits {
hits[j]++ // leading space
}
text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...)
}
line = Highlight(ansi.Truncate(text, inner, "…"), base, inner)
} else if len(hits) > 0 {
// Style just the matches, keeping the row's own colours.
line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...)
} else {
line = " " + line
}
body = append(body, line)
}
}
counter := ""
if len(l.visible) > 0 {
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
}
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
}
// scroll keeps the cursor within the visible window.
func (l *List[T]) scroll() {
rows := l.rows()
if rows <= 0 {
return
}
if l.cursor < l.offset {
l.offset = l.cursor
}
if l.cursor >= l.offset+rows {
l.offset = l.cursor - rows + 1
}
l.offset = max(0, min(l.offset, len(l.visible)-rows))
}
+179
View File
@@ -0,0 +1,179 @@
package panes
import (
"fmt"
"reflect"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func newStringList(n int) List[string] {
l := NewList("Items", Item[string]{
ID: func(s string) string { return s },
Filter: func(s string) string { return s },
Render: func(_ styles.Styles, s string, _ int) string { return s },
})
var items []string
for i := range n {
items = append(items, fmt.Sprintf("item-%02d", i))
}
l.SetItems(items)
l.SetSize(20, 7) // 5 rows
return l
}
func TestListScrollKeepsCursorVisible(t *testing.T) {
l := newStringList(20)
l.Move(9)
view := ansi.Strip(l.View(styles.Default(), true, ""))
if !strings.Contains(view, "item-09") || strings.Contains(view, "item-04") {
t.Fatalf("cursor row not scrolled into view:\n%s", view)
}
// Moving up within the window must not scroll.
l.Move(-2)
view = ansi.Strip(l.View(styles.Default(), true, ""))
if !strings.Contains(view, "item-05") {
t.Fatalf("window jumped when moving up inside it:\n%s", view)
}
l.Top()
if l.SelectedID() != "item-00" || l.offset != 0 {
t.Fatalf("Top: selected %q offset %d", l.SelectedID(), l.offset)
}
l.Bottom()
if l.SelectedID() != "item-19" || l.offset != 15 {
t.Fatalf("Bottom: selected %q offset %d", l.SelectedID(), l.offset)
}
}
func TestListSetItemsKeepsSelection(t *testing.T) {
l := newStringList(5)
l.Move(3)
l.SetItems([]string{"new", "item-03", "item-04"})
if l.SelectedID() != "item-03" {
t.Fatalf("selected = %q", l.SelectedID())
}
l.SetItems([]string{"other"})
if l.SelectedID() != "other" {
t.Fatalf("selected = %q after item vanished", l.SelectedID())
}
}
func TestBoxExactSize(t *testing.T) {
out := Box(styles.Default(), "a very long title that will not fit", "footer", []string{"x", strings.Repeat("y", 50)}, 12, 5, true)
lines := strings.Split(out, "\n")
if len(lines) != 5 {
t.Fatalf("height = %d", len(lines))
}
for _, line := range lines {
if w := ansi.StringWidth(line); w != 12 {
t.Errorf("width %d: %q", w, ansi.Strip(line))
}
}
}
func filterList(t *testing.T, items []string, query string) List[string] {
t.Helper()
l := NewList("Items", Item[string]{
ID: func(s string) string { return s },
Filter: func(s string) string { return s },
Render: func(_ styles.Styles, s string, _ int) string { return s },
})
l.SetItems(items)
l.SetSize(40, 10)
l.StartFilter()
for _, r := range query {
l.UpdateFilter(tea.KeyPressMsg{Code: r, Text: string(r)})
}
return l
}
func visibleNames(l List[string]) []string {
var out []string
for _, i := range l.visible {
out = append(out, l.items[i])
}
return out
}
func TestFuzzyFilterRanksAndSkips(t *testing.T) {
l := filterList(t, []string{"api-key", "db-password", "storage-key", "dbx-pat"}, "dbpw")
if got := visibleNames(l); !reflect.DeepEqual(got, []string{"db-password"}) {
t.Errorf("dbpw matched %v", got)
}
l = filterList(t, []string{"monkey", "key-vault", "api-key"}, "key")
if got := visibleNames(l); got[0] != "key-vault" || len(got) != 3 {
t.Errorf("prefix match not ranked first: %v", got)
}
}
func TestFilterHighlightsMatches(t *testing.T) {
l := filterList(t, []string{"db-password", "api-key"}, "dpw")
l.AcceptFilter()
view := l.View(styles.Default(), false, "")
// d, p and w are styled; with ANSI stripped the text is unchanged.
if !strings.Contains(ansi.Strip(view), "db-password") {
t.Fatalf("row missing:\n%s", ansi.Strip(view))
}
if hits := l.highlight(0, "db-password"); !reflect.DeepEqual(hits, []int{0, 3, 7}) {
t.Errorf("hits = %v", hits)
}
// Rows that show the Filter text at an offset get shifted hits.
if hits := l.highlight(0, " db-password (disabled)"); !reflect.DeepEqual(hits, []int{2, 5, 9}) {
t.Errorf("shifted hits = %v", hits)
}
}
func TestFilterKeepsRowColours(t *testing.T) {
blue := lipgloss.NewStyle().Foreground(lipgloss.Blue)
match := styles.Default().Match
if got := matchRanges("platform-dev", []int{9, 10, 11}, match); len(got) != 1 || got[0].Start != 9 || got[0].End != 12 {
t.Errorf("ranges = %+v, want one range 9-12", got)
}
// Only the matches change style; the rest keeps the row's colour.
styled := " " + blue.Render("platform-dev")
out := lipgloss.StyleRanges(styled, matchRanges(" platform-dev", []int{11, 12, 13}, match)...)
if ansi.Strip(out) != " platform-dev" {
t.Fatalf("text changed: %q", ansi.Strip(out))
}
if !strings.Contains(out, "\x1b[34m") {
t.Errorf("unmatched text lost its colour: %q", out)
}
}
func TestCounterInBottomRight(t *testing.T) {
out := BoxCounted(styles.Default(), "T", "footer", "3 of 12", []string{"x"}, 30, 3, true)
bottom := ansi.Strip(strings.Split(out, "\n")[2])
if !strings.HasSuffix(bottom, " 3 of 12 ─╯") || !strings.HasPrefix(bottom, "╰─ footer ") || ansi.StringWidth(bottom) != 30 {
t.Errorf("bottom edge = %q", bottom)
}
}
func TestHighlightKeepsColours(t *testing.T) {
blue := lipgloss.NewStyle().Foreground(lipgloss.Blue)
sel := lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true)
row := " " + blue.Render("platform-prod") + lipgloss.NewStyle().Faint(true).Render(" note")
out := Highlight(row, sel, 30)
if ansi.StringWidth(out) != 30 || ansi.Strip(out) != ansi.Strip(row)+strings.Repeat(" ", 30-ansi.StringWidth(row)) {
t.Fatalf("text or width changed: %q", ansi.Strip(out))
}
if !strings.Contains(out, "\x1b[34m") {
t.Error("row lost its blue")
}
// The background is re-applied after every reset, so it spans the row.
resets := strings.Count(out, "\x1b[m") + strings.Count(out, "\x1b[0m")
if bgs := strings.Count(out, "\x1b[100;1m"); bgs < resets {
t.Errorf("background not re-applied after each reset: %d resets, %d backgrounds\n%q", resets, bgs, out)
}
// Reverse styles fall back to plain text.
if got := Highlight(row, lipgloss.NewStyle().Reverse(true), 30); strings.Contains(got, "34m") {
t.Error("reverse kept the colours")
}
}
+76
View File
@@ -0,0 +1,76 @@
// Package styles holds the lipgloss theme. It sticks to the 16 ANSI colours
// so it follows the user's terminal palette in both light and dark themes.
package styles
import (
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
type Styles struct {
BorderFocused lipgloss.Style
BorderBlurred lipgloss.Style
TitleFocused lipgloss.Style
TitleBlurred lipgloss.Style
Footer lipgloss.Style
Selected lipgloss.Style // cursor row in the focused pane
SelectedBlurred lipgloss.Style // cursor row in other panes
Muted lipgloss.Style
Error lipgloss.Style
Notice lipgloss.Style
Success lipgloss.Style // the ✓ before confirmations
Warning lipgloss.Style // "can't do that here" messages
Match lipgloss.Style // filter matches
Favourite lipgloss.Style
Prompt lipgloss.Style // questions in the footer
Label lipgloss.Style
Masked lipgloss.Style
Value lipgloss.Style
KindAzure lipgloss.Style
KindDatabricks lipgloss.Style
KindRepo lipgloss.Style
}
func Default() Styles {
green := lipgloss.Green
return Styles{
BorderFocused: lipgloss.NewStyle().Foreground(green),
BorderBlurred: lipgloss.NewStyle().Faint(true),
TitleFocused: lipgloss.NewStyle().Foreground(green).Bold(true),
TitleBlurred: lipgloss.NewStyle(),
Footer: lipgloss.NewStyle().Faint(true),
// A background rather than reverse, so the row keeps its colours.
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
SelectedBlurred: lipgloss.NewStyle().Bold(true),
Muted: lipgloss.NewStyle().Faint(true),
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
Success: lipgloss.NewStyle().Foreground(lipgloss.Green).Bold(true),
Warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Match: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true),
Favourite: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
Prompt: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true),
Label: lipgloss.NewStyle().Foreground(lipgloss.Blue).Bold(true),
Masked: lipgloss.NewStyle().Faint(true),
Value: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
}
}
// Kind is the colour of a provider's sources, vaults and paths.
func (s Styles) Kind(k provider.Kind) lipgloss.Style {
switch k {
case provider.KindDatabricks:
return s.KindDatabricks
case provider.KindRepo:
return s.KindRepo
}
return s.KindAzure
}
+198
View File
@@ -0,0 +1,198 @@
package styles
import (
"fmt"
"image/color"
"slices"
"sort"
"strconv"
"strings"
"charm.land/lipgloss/v2"
)
// A theme maps roles to a colour and attributes, like lazygit's gui.theme:
//
// activeBorderColor: ["#e0af68", bold]
// selectedLineBgColor: ["#2d3f76"]
//
// Role names follow lazygit where the meaning is the same, so an editor
// plugin can map them from highlight groups the way snacks.nvim does for
// lazygit. Colours are hex (#rrggbb), a palette number (0-255) or a name
// (black, red, green, yellow, blue, magenta, cyan, white, their bright
// variants like brightred, or default). Attributes: bold, italic,
// underline, reverse, faint.
// Roles lists the theme roles with what they colour.
var Roles = map[string]string{
"activeBorderColor": "border and title of the focused pane",
"inactiveBorderColor": "borders of other panes",
"selectedLineBgColor": "background of the cursor row in the focused pane",
"inactiveViewSelectedLineBgColor": "background of the cursor row in other panes",
"defaultFgColor": "default text colour",
"optionsTextColor": "keys in the help bar and menus, and labels in the detail pane",
"searchMatchColor": "matched characters when filtering",
"errorColor": "✗ errors",
"noticeColor": "notices, the clipboard countdown and current versions",
"successColor": "the ✓ before confirmations (copied, opened…)",
"warningColor": "⚠ messages: what can't be done here, and why",
"mutedColor": "secondary text",
"favouriteColor": "stars and questions",
"valueColor": "revealed values",
"azureColor": "Azure sources and vaults",
"databricksColor": "Databricks workspaces and scopes",
"repoColor": "repos and their secret files (SOPS)",
}
// FromTheme returns the default styles with the theme's roles applied, and
// the default text colour if the theme sets one.
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
st := Default()
var fg color.Color
roles := make([]string, 0, len(theme))
for r := range theme {
roles = append(roles, r)
}
sort.Strings(roles)
for _, role := range roles {
if _, ok := Roles[role]; !ok {
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
}
v, err := parse(theme[role])
if err != nil {
return st, nil, fmt.Errorf("theme: %s: %w", role, err)
}
switch role {
case "activeBorderColor":
st.BorderFocused = v.fg(lipgloss.NewStyle())
st.TitleFocused = v.fg(lipgloss.NewStyle()).Bold(true)
case "inactiveBorderColor":
st.BorderBlurred = v.fg(lipgloss.NewStyle())
case "selectedLineBgColor":
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
case "inactiveViewSelectedLineBgColor":
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
case "defaultFgColor":
fg = v.color
case "optionsTextColor":
st.Label = v.fg(lipgloss.NewStyle())
case "searchMatchColor":
st.Match = v.fg(lipgloss.NewStyle())
case "errorColor":
st.Error = v.fg(lipgloss.NewStyle())
case "noticeColor":
st.Notice = v.fg(lipgloss.NewStyle())
case "warningColor":
st.Warning = v.fg(lipgloss.NewStyle())
case "successColor":
st.Success = v.fg(lipgloss.NewStyle()).Bold(true)
case "mutedColor":
st.Muted = v.fg(lipgloss.NewStyle())
st.Masked, st.Footer = st.Muted, st.Muted
case "favouriteColor":
st.Favourite = v.fg(lipgloss.NewStyle())
st.Prompt = v.fg(lipgloss.NewStyle()).Bold(true)
case "valueColor":
st.Value = v.fg(lipgloss.NewStyle())
case "azureColor":
st.KindAzure = v.fg(lipgloss.NewStyle())
case "databricksColor":
st.KindDatabricks = v.fg(lipgloss.NewStyle())
case "repoColor":
st.KindRepo = v.fg(lipgloss.NewStyle())
}
}
return st, fg, nil
}
func sortedRoles() []string {
var out []string
for r := range Roles {
out = append(out, r)
}
slices.Sort(out)
return out
}
// value is a parsed role: a colour (nil for default) and attributes.
type value struct {
color color.Color
bold, italic, underline, reverse, faint bool
}
// fg styles s with the value as foreground, bg as background.
func (v value) fg(s lipgloss.Style) lipgloss.Style {
if v.color != nil {
s = s.Foreground(v.color)
}
return v.attrs(s)
}
func (v value) bg(s lipgloss.Style) lipgloss.Style {
if v.color != nil {
s = s.Background(v.color)
}
return v.attrs(s)
}
func (v value) attrs(s lipgloss.Style) lipgloss.Style {
return s.Bold(v.bold).Italic(v.italic).Underline(v.underline).Reverse(v.reverse).Faint(v.faint)
}
var names = map[string]color.Color{
"black": lipgloss.Black, "red": lipgloss.Red, "green": lipgloss.Green, "yellow": lipgloss.Yellow,
"blue": lipgloss.Blue, "magenta": lipgloss.Magenta, "cyan": lipgloss.Cyan, "white": lipgloss.White,
"brightblack": lipgloss.BrightBlack, "brightred": lipgloss.BrightRed, "brightgreen": lipgloss.BrightGreen,
"brightyellow": lipgloss.BrightYellow, "brightblue": lipgloss.BrightBlue,
"brightmagenta": lipgloss.BrightMagenta, "brightcyan": lipgloss.BrightCyan, "brightwhite": lipgloss.BrightWhite,
}
func parse(items []string) (value, error) {
var v value
for _, it := range items {
s := strings.ToLower(strings.TrimSpace(it))
switch s {
case "bold":
v.bold = true
case "italic":
v.italic = true
case "underline":
v.underline = true
case "reverse":
v.reverse = true
case "faint", "dim":
v.faint = true
case "default", "":
// the terminal's own colour
default:
c, err := parseColor(s)
if err != nil {
return v, err
}
v.color = c
}
}
return v, nil
}
func parseColor(s string) (color.Color, error) {
if c, ok := names[s]; ok {
return c, nil
}
if strings.HasPrefix(s, "#") {
if len(s) != 7 {
return nil, fmt.Errorf("%q: want #rrggbb", s)
}
if _, err := strconv.ParseUint(s[1:], 16, 32); err != nil {
return nil, fmt.Errorf("%q: want #rrggbb", s)
}
return lipgloss.Color(s), nil
}
if n, err := strconv.Atoi(s); err == nil {
if n < 0 || n > 255 {
return nil, fmt.Errorf("%q: palette numbers are 0-255", s)
}
return lipgloss.Color(s), nil
}
return nil, fmt.Errorf("%q isn't a colour or attribute", s)
}
+64
View File
@@ -0,0 +1,64 @@
package styles
import (
"strings"
"testing"
"charm.land/lipgloss/v2"
)
func TestFromTheme(t *testing.T) {
st, fg, err := FromTheme(map[string][]string{
"activeBorderColor": {"#e0af68", "bold"},
"selectedLineBgColor": {"#2d3f76"},
"defaultFgColor": {"#c8d3f5"},
"azureColor": {"75"},
"databricksColor": {"brightred"},
"searchMatchColor": {"default", "underline"},
})
if err != nil {
t.Fatal(err)
}
if fg == nil {
t.Error("defaultFgColor not returned")
}
if got := st.BorderFocused.GetForeground(); got != lipgloss.Color("#e0af68") || !st.BorderFocused.GetBold() {
t.Errorf("active border = %v bold=%v", got, st.BorderFocused.GetBold())
}
// The theme's selected-line background replaces the default one.
if st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.Color("#2d3f76") {
t.Errorf("selected = reverse %v bg %v", st.Selected.GetReverse(), st.Selected.GetBackground())
}
if st.KindAzure.GetForeground() != lipgloss.Color("75") || st.KindDatabricks.GetForeground() != lipgloss.BrightRed {
t.Error("provider colours not applied")
}
if !st.Match.GetUnderline() {
t.Error("attribute without colour not applied")
}
// Roles the theme leaves out keep their defaults.
if st.Error.GetForeground() != lipgloss.Red {
t.Error("default error colour changed")
}
}
func TestFromThemeRejectsMistakes(t *testing.T) {
for name, theme := range map[string]map[string][]string{
"role": {"activeBorder": {"red"}},
"colour": {"errorColor": {"redish"}},
"hex": {"errorColor": {"#fff"}},
"palette": {"errorColor": {"300"}},
} {
if _, _, err := FromTheme(theme); err == nil {
t.Errorf("%s: no error", name)
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
t.Errorf("unknown role error should list the known ones: %v", err)
}
}
}
func TestEmptyThemeIsDefault(t *testing.T) {
st, fg, err := FromTheme(nil)
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
}
}
+246
View File
@@ -0,0 +1,246 @@
// Package update finds newer lazysecrets releases and installs one in
// place of the running binary: the archive for this OS and CPU, checked
// against the release's checksums.txt, tried (--version), then renamed
// over the binary. A running lazysecrets keeps its old binary until it
// restarts.
package update
import (
"archive/tar"
"bufio"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
"time"
"golang.org/x/mod/semver"
)
// Where releases are published.
const (
DefaultAPI = "https://git.kebcloud.no/api/v1/repos/keb/lazysecrets"
DefaultRepo = "https://git.kebcloud.no/keb/lazysecrets"
)
// maxDownload bounds what's read from the server.
const maxDownload = 200 << 20
// Release is a published release.
type Release struct {
Tag string `json:"tag_name"`
Body string `json:"body"` // the changelog section, then install notes
URL string `json:"html_url"`
}
// Notes is the release's changelog: its body without the install notes
// every release ends with.
func (r Release) Notes() string {
notes, _, _ := strings.Cut(r.Body, "## Install")
return strings.TrimSpace(notes)
}
// Client talks to the release server.
type Client struct {
API string // the repository's API, DefaultAPI when empty
Repo string // the repository's web address, DefaultRepo when empty
HTTP *http.Client
}
func (c *Client) api() string { return cmpOr(c.API, DefaultAPI) }
func (c *Client) repo() string { return cmpOr(c.Repo, DefaultRepo) }
func (c *Client) client() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 2 * time.Minute}
}
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
// Latest is the newest release (pre-releases aside).
func (c *Client) Latest(ctx context.Context) (Release, error) {
b, err := c.get(ctx, c.api()+"/releases/latest", 1<<20)
if err != nil {
return Release{}, err
}
var r Release
if err := json.Unmarshal(b, &r); err != nil {
return Release{}, fmt.Errorf("reading the latest release: %w", err)
}
if !semver.IsValid(r.Tag) {
return Release{}, fmt.Errorf("latest release %q isn't a version", r.Tag)
}
return r, nil
}
// Newer reports whether latest is newer than current. A build that isn't
// a version (dev-…) is never offered one.
func Newer(current, latest string) bool {
current, latest = canonical(current), canonical(latest)
return semver.IsValid(current) && semver.IsValid(latest) && semver.Compare(latest, current) > 0
}
func canonical(v string) string {
if v != "" && !strings.HasPrefix(v, "v") {
v = "v" + v
}
return v
}
// Managed says which package manager owns exe, if one does: it updates
// lazysecrets there, not lazysecrets itself.
func Managed(exe string) string {
switch {
case strings.Contains(exe, "/Cellar/") || strings.HasPrefix(exe, "/opt/homebrew/") || strings.Contains(exe, "/linuxbrew/"):
return "Homebrew (brew upgrade lazysecrets)"
case strings.HasPrefix(exe, "/nix/store/"):
return "Nix"
case strings.HasPrefix(exe, "/usr/bin/") || strings.HasPrefix(exe, "/bin/"):
return "your system's package manager"
}
return ""
}
// Executable is the running binary's real path.
func Executable() (string, error) {
exe, err := os.Executable()
if err != nil {
return "", err
}
return filepath.EvalSymlinks(exe)
}
// Install replaces the binary at exe with r's, for this OS and CPU.
func (c *Client) Install(ctx context.Context, r Release, exe string) error {
if m := Managed(exe); m != "" {
return fmt.Errorf("%s is managed by %s: update it there", exe, m)
}
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" || runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" {
return fmt.Errorf("no release build for %s/%s", runtime.GOOS, runtime.GOARCH)
}
version := strings.TrimPrefix(r.Tag, "v")
name := fmt.Sprintf("lazysecrets_%s_%s_%s", version, runtime.GOOS, runtime.GOARCH)
base := c.repo() + "/releases/download/" + r.Tag + "/"
sums, err := c.get(ctx, base+"checksums.txt", 1<<20)
if err != nil {
return err
}
want, err := checksum(sums, name+".tar.gz")
if err != nil {
return err
}
archive, err := c.get(ctx, base+name+".tar.gz", maxDownload)
if err != nil {
return err
}
if got := sha256.Sum256(archive); hex.EncodeToString(got[:]) != want {
return fmt.Errorf("%s.tar.gz doesn't match checksums.txt: damaged, or not the release", name)
}
bin, err := extract(archive, path.Join(name, "lazysecrets"))
if err != nil {
return err
}
// Next to the binary, so the rename that replaces it is atomic.
dir := filepath.Dir(exe)
tmp, err := os.CreateTemp(dir, ".lazysecrets-update-*")
if err != nil {
return fmt.Errorf("can't write next to %s (%w): reinstall it with install.sh, or update as the user who owns it", exe, err)
}
defer os.Remove(tmp.Name()) // gone after the rename; cleans up on failure
if _, err := tmp.Write(bin); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0o755); err != nil {
return err
}
// It must run, and be the version asked for, before it replaces
// anything.
out, err := exec.CommandContext(ctx, tmp.Name(), "--version").Output()
if err != nil || !strings.Contains(string(out), version) {
return fmt.Errorf("the downloaded %s doesn't run as expected (%q, %v)", r.Tag, strings.TrimSpace(string(out)), err)
}
if err := os.Rename(tmp.Name(), exe); err != nil {
return fmt.Errorf("replacing %s: %w", exe, err)
}
return nil
}
func (c *Client) get(ctx context.Context, url string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := c.client().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", url, resp.Status)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(b)) > limit {
return nil, fmt.Errorf("%s: larger than expected", url)
}
return b, nil
}
// checksum finds file's sha256 in a checksums.txt.
func checksum(sums []byte, file string) (string, error) {
sc := bufio.NewScanner(bytes.NewReader(sums))
for sc.Scan() {
if f := strings.Fields(sc.Text()); len(f) == 2 && f[1] == file {
return f[0], nil
}
}
return "", fmt.Errorf("%s isn't in checksums.txt", file)
}
// extract reads one file from a .tar.gz.
func extract(archive []byte, name string) ([]byte, error) {
gz, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, err
}
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if errors.Is(err, io.EOF) {
return nil, fmt.Errorf("%s isn't in the archive", name)
}
if err != nil {
return nil, err
}
if h.Name == name && h.Typeflag == tar.TypeReg {
return io.ReadAll(io.LimitReader(tr, maxDownload))
}
}
}
+125
View File
@@ -0,0 +1,125 @@
package update
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestNewer(t *testing.T) {
for _, c := range []struct {
current, latest string
want bool
}{
{"0.1.1", "v0.2.0", true},
{"v0.2.0", "v0.2.0", false},
{"0.3.0", "v0.2.0", false},
{"v0.1.2-0.20261009185704-2ba762ce2224", "v0.1.1", false}, // go install of a later commit
{"v0.1.2-0.20261009185704-2ba762ce2224", "v0.1.2", true},
{"dev-2ba762c", "v0.2.0", false},
{"dev", "v0.2.0", false},
{"", "v0.2.0", false},
} {
if got := Newer(c.current, c.latest); got != c.want {
t.Errorf("Newer(%q, %q) = %v", c.current, c.latest, got)
}
}
}
func TestNotes(t *testing.T) {
r := Release{Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\nOn macOS…"}
if got := r.Notes(); got != "### Added\n\n- Install with a one-liner (#7)" {
t.Errorf("notes %q", got)
}
}
func TestManaged(t *testing.T) {
if Managed("/opt/homebrew/Cellar/lazysecrets/0.2.0/bin/lazysecrets") == "" || Managed("/nix/store/abc-lazysecrets/bin/lazysecrets") == "" {
t.Error("package managers not recognised")
}
if m := Managed("/Users/x/.local/bin/lazysecrets"); m != "" {
t.Errorf("~/.local/bin is ours: %q", m)
}
}
// server publishes v0.2.0 with a "binary" (a script) printing its version.
func server(t *testing.T, tamper bool) *httptest.Server {
t.Helper()
name := fmt.Sprintf("lazysecrets_0.2.0_%s_%s", runtime.GOOS, runtime.GOARCH)
var archive bytes.Buffer
gz := gzip.NewWriter(&archive)
tw := tar.NewWriter(gz)
script := []byte("#!/bin/sh\necho lazysecrets 0.2.0\n")
tw.WriteHeader(&tar.Header{Name: name + "/lazysecrets", Mode: 0o755, Size: int64(len(script)), Typeflag: tar.TypeReg})
tw.Write(script)
tw.Close()
gz.Close()
sum := sha256.Sum256(archive.Bytes())
if tamper {
sum[0] ^= 0xff
}
mux := http.NewServeMux()
mux.HandleFunc("/api/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprint(w, `{"tag_name":"v0.2.0","body":"### Added\n\n- x\n\n## Install\n","html_url":"https://example.com/r"}`)
})
mux.HandleFunc("/repo/releases/download/v0.2.0/checksums.txt", func(w http.ResponseWriter, _ *http.Request) {
fmt.Fprintf(w, "%s %s.tar.gz\n", hex.EncodeToString(sum[:]), name)
})
mux.HandleFunc("/repo/releases/download/v0.2.0/"+name+".tar.gz", func(w http.ResponseWriter, _ *http.Request) {
w.Write(archive.Bytes())
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestInstall(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux")
}
srv := server(t, false)
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
r, err := c.Latest(context.Background())
if err != nil || r.Tag != "v0.2.0" {
t.Fatalf("latest %+v %v", r, err)
}
exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755)
if err := c.Install(context.Background(), r, exe); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(exe); !strings.Contains(string(b), "echo lazysecrets 0.2.0") {
t.Errorf("not replaced: %q", b)
}
if left, _ := filepath.Glob(filepath.Join(filepath.Dir(exe), ".lazysecrets-update-*")); len(left) > 0 {
t.Errorf("left behind: %v", left)
}
}
func TestInstallRefusesATamperedArchive(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("releases are for macOS and Linux")
}
srv := server(t, true)
c := &Client{API: srv.URL + "/api", Repo: srv.URL + "/repo"}
exe := filepath.Join(t.TempDir(), "lazysecrets")
os.WriteFile(exe, []byte("old"), 0o755)
err := c.Install(context.Background(), Release{Tag: "v0.2.0"}, exe)
if err == nil || !strings.Contains(err.Error(), "doesn't match checksums.txt") {
t.Fatalf("err %v", err)
}
if b, _ := os.ReadFile(exe); string(b) != "old" {
t.Error("replaced anyway")
}
}