Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
430875f200
111 files changed
+22958
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 + " "
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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") }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user