feat: share env files as encrypted links (#54)
Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link. **Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file). - **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference. - **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels. - **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening. - **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them. - **The command line:** `lazysecrets share [--key <secret>] <env files>`. **Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`. 1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`. 2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore. 3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written. 4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged. **Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable. **Safety:** - A link can only write env files, inside the folder: never in `.git`, never through a symlink. - New files are created with mode 0600. - Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB. **Along the way:** - Opening a link to a secret ends on its details, as `enter` would. - `o` on an Azure secret opens its portal page, and in the versions view, that version's page. - The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address. - `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning. - `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there. - New dependency: `filippo.io/age`. Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: #54
This commit was merged in pull request #54.
This commit is contained in:
1 parent
2044341b20
commit
4377eb98bf
40 files changed
+2449
-138
No files matched your search
@@ -24,6 +24,7 @@ import (
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
@@ -81,6 +82,11 @@ type Options struct {
|
||||
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||
// run only.
|
||||
CacheFile string
|
||||
// ShareKey is the config's share.key: the secret that encrypts shared
|
||||
// env files, for repos whose .lazysecrets.yaml names none.
|
||||
ShareKey string
|
||||
// ShareRules are what a key's value must be to share with it.
|
||||
ShareRules share.Rules
|
||||
// Dir is the folder lazysecrets runs in: it goes back to where it was
|
||||
// left there, unless it was started on a link. "" doesn't.
|
||||
Dir string
|
||||
@@ -270,6 +276,9 @@ type Model struct {
|
||||
linked []provider.Container // vaults opened from links, this session
|
||||
open *ref.Ref // reference to open at startup
|
||||
|
||||
shareKey string // the config's share.key
|
||||
shareRules share.Rules // and share.keyRules
|
||||
|
||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||
dismissedLink string // the last link offered: not offered again
|
||||
clipChecked time.Time
|
||||
@@ -455,6 +464,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
m.spinning = true
|
||||
|
||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
||||
m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
|
||||
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
|
||||
if m.cachePath != "" {
|
||||
m.loadCacheFile()
|
||||
@@ -570,6 +580,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case resolvedMsg:
|
||||
m.handleResolved(msg)
|
||||
|
||||
case shareFilesMsg:
|
||||
cmds = append(cmds, m.handleShareFiles(msg))
|
||||
|
||||
case sealedMsg:
|
||||
cmds = append(cmds, m.handleSealed(msg))
|
||||
|
||||
case shareOpenedMsg:
|
||||
cmds = append(cmds, m.handleShareOpened(msg))
|
||||
|
||||
case tea.PasteMsg:
|
||||
cmds = append(cmds, m.handlePaste(msg))
|
||||
|
||||
@@ -660,12 +679,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if msg.bound != nil {
|
||||
msg.key = m.rebind(*msg.bound)
|
||||
}
|
||||
before := m.cache.objects[msg.key].value
|
||||
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
||||
m.cacheDirty = true
|
||||
if m.versionsOf != nil {
|
||||
m.setVersions(msg.items)
|
||||
} else {
|
||||
m.secrets.SetItems(m.starredFirst(msg.items))
|
||||
cmds = append(cmds, m.refreshShown(before))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
@@ -48,3 +52,31 @@ func (c cache) rememberCursor(parent, child string) {
|
||||
c.cursor[parent] = child
|
||||
}
|
||||
}
|
||||
|
||||
// refreshShown shows the selected secret's newer metadata when its list
|
||||
// reloaded with it changed from before (a cached list refreshed in the
|
||||
// background), and describes it afresh: its cached details came from
|
||||
// the old listing.
|
||||
func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
|
||||
s, ok := m.secrets.Selected()
|
||||
if !ok || m.secrets.SelectedID() != m.selSecret {
|
||||
return nil // a new selection is shown as usual
|
||||
}
|
||||
i := slices.IndexFunc(before, func(o provider.Object) bool { return o.ID == s.ID })
|
||||
if i < 0 || sameListing(before[i], s) {
|
||||
return nil
|
||||
}
|
||||
m.detail.Refresh(s)
|
||||
delete(m.cache.details, detailKey(s))
|
||||
return m.describe(s)
|
||||
}
|
||||
|
||||
// sameListing reports whether a and b list the same: what a list
|
||||
// says of an object, not what describing it adds.
|
||||
func sameListing(a, b provider.Object) bool {
|
||||
enabled := func(e *bool) bool { return e == nil || *e }
|
||||
return a.Name == b.Name && a.Version == b.Version && enabled(a.Enabled) == enabled(b.Enabled) &&
|
||||
a.Updated.Equal(b.Updated) && a.Expires.Equal(b.Expires) && a.ContentType == b.ContentType &&
|
||||
maps.Equal(a.Tags, b.Tags) && a.Reference == b.Reference && a.URL == b.URL &&
|
||||
slices.Equal(a.Attrs, b.Attrs)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -159,3 +160,33 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) {
|
||||
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadedListRefreshesShownSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, _, clock := newCachingDriver(t)
|
||||
d.keys("3") // kv-app-prod's secrets, db-password selected
|
||||
shown, _ := d.m.detail.Secret()
|
||||
// What an older lazysecrets listed and cached for it.
|
||||
const old = "https://portal.example/old"
|
||||
key := d.m.selContainer
|
||||
e := d.m.cache.objects[key]
|
||||
items := slices.Clone(e.value)
|
||||
for i := range items {
|
||||
if items[i].ID == shown.ID {
|
||||
items[i].URL = old
|
||||
}
|
||||
}
|
||||
d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at}
|
||||
stale := shown
|
||||
stale.URL = old
|
||||
d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock}
|
||||
|
||||
// Away and back once the list is stale: it's shown from the cache,
|
||||
// then reloaded, and the details pane follows.
|
||||
d.keys("1", "j")
|
||||
*clock = clock.Add(cacheTTL + time.Second)
|
||||
d.keys("k", "3")
|
||||
if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old {
|
||||
t.Errorf("shown %s with %q after the reload", got.Name, got.URL)
|
||||
}
|
||||
}
|
||||
+26
-1
@@ -47,6 +47,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
|
||||
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),
|
||||
plain: m.inPlainEnv(r),
|
||||
run: func(m *Model) tea.Cmd {
|
||||
m.used(key)
|
||||
return run(m)
|
||||
@@ -138,6 +139,30 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
|
||||
return label
|
||||
}
|
||||
|
||||
// inPlainEnv reports whether r is a key in a plain env file, as far as
|
||||
// the files loaded tell.
|
||||
func (m Model) inPlainEnv(r ref.Ref) bool {
|
||||
if r.Kind != provider.KindRepo || r.Name == "" {
|
||||
return false
|
||||
}
|
||||
file := r
|
||||
file.Name, file.Type = "", ""
|
||||
for _, e := range m.cache.containers {
|
||||
for _, c := range e.value {
|
||||
if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
|
||||
// icon and colour, "app / apps/api/.env".
|
||||
func (m Model) repoLabel(repo string, file ...string) string {
|
||||
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
|
||||
}
|
||||
|
||||
// sourceMatches reports whether src is the subscription or workspace r
|
||||
// links to.
|
||||
func sourceMatches(r ref.Ref, src provider.Source) bool {
|
||||
@@ -192,7 +217,7 @@ func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
|
||||
case 1:
|
||||
m.startNav(r, actGo, choices[0].sourceRow)
|
||||
default:
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
|
||||
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -46,6 +46,9 @@ type nav struct {
|
||||
// quiet gives up without a word: going back to where lazysecrets
|
||||
// was left, which may be gone.
|
||||
quiet bool
|
||||
// link ends on the object's details, as enter on it would: opening
|
||||
// a link to it opens it, rather than just finding it in its list.
|
||||
link bool
|
||||
}
|
||||
|
||||
type resolvedMsg struct {
|
||||
@@ -78,6 +81,9 @@ const (
|
||||
// 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.IsShare() {
|
||||
return m.offerShare(r)
|
||||
}
|
||||
if r.IsContainer() || r.IsSource() {
|
||||
if !ask {
|
||||
return m.openRef(r, actGo)
|
||||
@@ -398,23 +404,30 @@ func (m *Model) handleResolved(msg resolvedMsg) {
|
||||
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)
|
||||
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices, true)
|
||||
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)
|
||||
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices, true)
|
||||
case msg.linked != nil:
|
||||
m.addLinked(*msg.linked)
|
||||
m.startNav(msg.ref, msg.act, linksRowID)
|
||||
m.startLinkNav(msg.ref, msg.act, linksRowID)
|
||||
default:
|
||||
m.startNav(msg.ref, msg.act, msg.sourceRow)
|
||||
m.startLinkNav(msg.ref, msg.act, msg.sourceRow)
|
||||
}
|
||||
}
|
||||
|
||||
// startLinkNav opens a link: like startNav, ending on its object's
|
||||
// details.
|
||||
func (m *Model) startLinkNav(r ref.Ref, act linkAction, sourceRow string) {
|
||||
m.startNav(r, act, sourceRow)
|
||||
m.nav.link = true
|
||||
}
|
||||
|
||||
// chooseSource asks where to open r.
|
||||
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
|
||||
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice, link bool) {
|
||||
var opts []option
|
||||
for i, c := range choices {
|
||||
key := ""
|
||||
@@ -424,6 +437,7 @@ func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []
|
||||
row := c.sourceRow
|
||||
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
|
||||
m.startNav(r, act, row)
|
||||
m.nav.link = link
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
@@ -574,6 +588,9 @@ func (m *Model) advanceNav() bool {
|
||||
m.pendingCmd = m.toggleReveal()
|
||||
}
|
||||
}
|
||||
if n.link {
|
||||
m.focus = paneDetail // after: copying acts on the focused pane
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -607,6 +624,9 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if s.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
|
||||
}
|
||||
if m.canShareFiles(s) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to its env files", run: func(m *Model) tea.Cmd { return m.shareFiles(s, nil) }})
|
||||
}
|
||||
m.showMenu("Share "+s.Name, nil, opts)
|
||||
case paneContainers:
|
||||
c, ok := m.containers.Selected()
|
||||
@@ -629,6 +649,11 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if c.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
|
||||
}
|
||||
if c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to the file", run: func(m *Model) tea.Cmd {
|
||||
return m.shareFiles(c.Source, []provider.Container{c})
|
||||
}})
|
||||
}
|
||||
m.showMenu("Share "+c.Name, nil, opts)
|
||||
default:
|
||||
o, ok := m.detail.Secret()
|
||||
@@ -653,6 +678,11 @@ func (m *Model) share(menu bool) tea.Cmd {
|
||||
if o.URL != "" {
|
||||
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
|
||||
}
|
||||
if c := o.Container; c.Detail == provider.DetailPlainEnv && m.canShareFiles(c.Source) {
|
||||
opts = append(opts, option{key: "f", label: "Encrypted link to its file", run: func(m *Model) tea.Cmd {
|
||||
return m.shareFiles(c.Source, []provider.Container{c})
|
||||
}})
|
||||
}
|
||||
m.showMenu("Share "+o.Name, nil, opts)
|
||||
}
|
||||
return nil
|
||||
@@ -802,6 +832,9 @@ func (m *Model) openText(s string) tea.Cmd {
|
||||
|
||||
// handlePaste opens a pasted reference, unless a text input has focus.
|
||||
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
|
||||
if m.search != nil && m.search.picker != nil {
|
||||
return m.pickPasted(msg.Content) // a reference to the secret
|
||||
}
|
||||
if p := m.prompt; p != nil && p.input != nil {
|
||||
in, cmd := p.input.Update(msg)
|
||||
p.input = &in
|
||||
|
||||
@@ -40,7 +40,8 @@ func TestOpenSecretLink(t *testing.T) {
|
||||
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 {
|
||||
// Opened: on its details, as enter on it would; its value hidden.
|
||||
if d.m.focus != paneDetail || d.m.detail.State() != panes.ValueHidden {
|
||||
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
@@ -40,6 +42,7 @@ type option struct {
|
||||
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)
|
||||
plain bool // a key in a plain env file: no key to encrypt with (Go to)
|
||||
run func(m *Model) tea.Cmd
|
||||
// header makes this a section title: not selectable, and hidden
|
||||
// while a filter narrows the list.
|
||||
@@ -53,9 +56,13 @@ type modal struct {
|
||||
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 text input instead of options; submit runs on enter (named
|
||||
// submitLabel in the footer; "open" if empty). err says why the last
|
||||
// one wasn't taken, under the input.
|
||||
input *textinput.Model
|
||||
submit func(m *Model, text string) tea.Cmd
|
||||
submitLabel string
|
||||
err string
|
||||
|
||||
// A filter over the options: typing narrows them (all holds them
|
||||
// all), so moving uses arrows or ctrl+j/k.
|
||||
@@ -259,7 +266,10 @@ func (m Model) modalView() string {
|
||||
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"))
|
||||
if p.err != "" {
|
||||
body = append(body, "", " "+m.st.Error.Render("✗ "+capitalize(p.err)))
|
||||
}
|
||||
foot = footer(hint(km.PopupSelect, cmp.Or(p.submitLabel, "open")), hint(km.PopupClose, "cancel"))
|
||||
}
|
||||
|
||||
// Size from all options, not the ones shown or the cursor's, so the
|
||||
@@ -406,3 +416,9 @@ func (m Model) overlay(base string) string {
|
||||
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||
).Render()
|
||||
}
|
||||
|
||||
// capitalize upper-cases the first letter of a message.
|
||||
func capitalize(s string) string {
|
||||
r, n := utf8.DecodeRuneInString(s)
|
||||
return string(unicode.ToUpper(r)) + s[n:]
|
||||
}
|
||||
@@ -61,13 +61,14 @@ func (m *Model) searchEntries() (sources, items []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) {
|
||||
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) *option {
|
||||
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}})
|
||||
return &es[len(es)-1].opt
|
||||
}
|
||||
for _, p := range m.providers {
|
||||
for _, src := range m.sourceResults[p.ID()].sources {
|
||||
@@ -98,10 +99,11 @@ func (m *Model) searchEntries() (sources, items []option) {
|
||||
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 {
|
||||
e := 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)
|
||||
e.plain = c.Detail == provider.DetailPlainEnv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+106
-11
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"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/store"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
@@ -52,6 +53,9 @@ type searchView struct {
|
||||
res int // cursor in shown
|
||||
here string // the scope of the source Go to was opened from, if any
|
||||
|
||||
// picker, if set, makes Go to pick a secret instead of going to it.
|
||||
picker *picker
|
||||
|
||||
// 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.
|
||||
@@ -267,6 +271,9 @@ func (v *searchView) match() []option {
|
||||
}
|
||||
}
|
||||
}
|
||||
if v.picker != nil {
|
||||
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
|
||||
}
|
||||
q := v.input.Value()
|
||||
if q == "" {
|
||||
return slices.Clone(cand)
|
||||
@@ -423,9 +430,14 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
|
||||
return nil
|
||||
case key.Matches(msg, k.sel):
|
||||
switch {
|
||||
case v.results && v.res < len(v.shown) && v.picker != nil:
|
||||
r, _ := pickable(v.shown[v.res])
|
||||
return v.picker.choose(m, r)
|
||||
case v.results && v.res < len(v.shown):
|
||||
m.search = nil
|
||||
return v.shown[v.res].run(m)
|
||||
case !v.results && v.picker != nil:
|
||||
v.results = true // a picker only narrows to the scope
|
||||
case !v.results && v.onSource():
|
||||
m.search = nil
|
||||
return v.scope().run(m)
|
||||
@@ -641,7 +653,16 @@ func (m Model) searchViewBox() string {
|
||||
v := m.search
|
||||
focused := !m.termBlurred
|
||||
w := searchWidth(m.w)
|
||||
popupW := w
|
||||
if v.picker != nil {
|
||||
w -= 4 // inside the picker's border, in the popup
|
||||
}
|
||||
listH := max(5, min(m.h-10, 14))
|
||||
if v.picker != nil {
|
||||
// What it's for, the help, a line or two on the key picked, the
|
||||
// tabs and a border more.
|
||||
listH = max(5, min(m.h-10-len(v.picker.lines)-len(v.picker.help)-8, 14))
|
||||
}
|
||||
|
||||
srcW := 10
|
||||
for _, o := range v.scopes {
|
||||
@@ -733,7 +754,24 @@ func (m Model) searchViewBox() string {
|
||||
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
|
||||
}
|
||||
|
||||
body := []string{" " + v.input.View(), ""}
|
||||
ts := panes.TitleStyle(m.st, focused)
|
||||
var body []string
|
||||
if p := v.picker; p != nil {
|
||||
for _, l := range p.help {
|
||||
body = append(body, " "+l)
|
||||
}
|
||||
switch {
|
||||
case p.busy != "":
|
||||
body = append(body, "", " "+m.st.Notice.Render(p.busy))
|
||||
case p.err != "":
|
||||
body = append(body, "")
|
||||
for _, l := range strings.Split(ansi.Wordwrap(p.err, w-4, " "), "\n") {
|
||||
body = append(body, " "+m.st.Error.Render(l))
|
||||
}
|
||||
}
|
||||
body = append(body, "", " "+m.goToTabs(ts))
|
||||
}
|
||||
body = append(body, " "+v.input.View(), "")
|
||||
for _, l := range strings.Split(lists, "\n") {
|
||||
body = append(body, " "+l)
|
||||
}
|
||||
@@ -753,26 +791,83 @@ func (m Model) searchViewBox() string {
|
||||
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"))...)
|
||||
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, v.selectLabel()), 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"))...)
|
||||
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, v.selectLabel()), 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 + " ")
|
||||
// Tabs in the title, the current one underlined, like the panes; a
|
||||
// picker has its own title, and the tabs over the input.
|
||||
if v.picker == nil {
|
||||
head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
|
||||
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
|
||||
}
|
||||
// A picker: in its own border, under what it's for.
|
||||
var outer []string
|
||||
for _, l := range v.picker.lines {
|
||||
outer = append(outer, " "+l)
|
||||
}
|
||||
outer = append(outer, "")
|
||||
for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.picker.heading), "", body, w, len(body)+2, focused), "\n") {
|
||||
outer = append(outer, " "+l)
|
||||
}
|
||||
return panes.Box(m.st, ts.Render(v.picker.title), foot, outer, popupW, len(outer)+2, focused)
|
||||
}
|
||||
|
||||
// picker is Go to picking a secret for something (the key to share
|
||||
// with): it lists secrets only, and enter hands the selected one's link
|
||||
// to pick, which closes Go to when it's done with it.
|
||||
type picker struct {
|
||||
pick func(m *Model, r ref.Ref) tea.Cmd
|
||||
title string // the popup's
|
||||
lines []string // what picking is for, above the picker
|
||||
heading string // the picker's title, on its border
|
||||
help []string // inside it, above the tabs
|
||||
busy string // what the pick is doing (picks wait meanwhile)
|
||||
err string // why the secret picked can't be used
|
||||
}
|
||||
|
||||
// choose hands r to the picker, unless it's still busy with one.
|
||||
func (p *picker) choose(m *Model, r ref.Ref) tea.Cmd {
|
||||
if p.busy != "" {
|
||||
return nil
|
||||
}
|
||||
return p.pick(m, r)
|
||||
}
|
||||
|
||||
// selectLabel names what enter does with the selected entry.
|
||||
func (v *searchView) selectLabel() string {
|
||||
if v.picker != nil {
|
||||
return "pick"
|
||||
}
|
||||
return "go"
|
||||
}
|
||||
|
||||
// pickable is the link of a Go to entry that a picker can pick: a
|
||||
// secret (not a key or certificate, nor a key in a plain env file).
|
||||
func pickable(o option) (ref.Ref, bool) {
|
||||
r, err := ref.Parse(o.star)
|
||||
if err != nil || o.plain || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
|
||||
return ref.Ref{}, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// goToTabs draws Go to's tabs, the current one underlined.
|
||||
func (m Model) goToTabs(ts lipgloss.Style) string {
|
||||
var out string
|
||||
for i, name := range tabNames {
|
||||
if i > 0 {
|
||||
head += ts.Render(" - ")
|
||||
out += ts.Render(" - ")
|
||||
}
|
||||
if i == v.tab {
|
||||
head += ts.Underline(true).Render(name)
|
||||
if i == m.search.tab {
|
||||
out += ts.Underline(true).Render(name)
|
||||
} else {
|
||||
head += m.st.Muted.Render(name)
|
||||
out += m.st.Muted.Render(name)
|
||||
}
|
||||
}
|
||||
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"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/ref"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/share"
|
||||
)
|
||||
|
||||
// Sharing env files as an encrypted lazysecrets://share link (package
|
||||
// share), and opening one: the files are encrypted with a secret's value,
|
||||
// so whoever can read that secret can open the link.
|
||||
|
||||
// sharing is a share being made: the files read, and the key to use.
|
||||
type sharing struct {
|
||||
repo provider.Source
|
||||
files []share.File
|
||||
key ref.Ref // zero until chosen
|
||||
}
|
||||
|
||||
type shareFilesMsg struct {
|
||||
sharing
|
||||
err error
|
||||
}
|
||||
|
||||
type sealedMsg struct {
|
||||
link ref.Ref
|
||||
n int
|
||||
// from is the picker the key was picked in; nil for share.key's.
|
||||
from *picker
|
||||
err error
|
||||
}
|
||||
|
||||
// canShareFiles reports whether src's env files can be shared: it's a
|
||||
// repo whose files can be read.
|
||||
func (m Model) canShareFiles(src provider.Source) bool {
|
||||
_, ok := m.providerFor(src).(provider.Contents)
|
||||
return src.Kind == provider.KindRepo && ok
|
||||
}
|
||||
|
||||
// shareFiles shares env files of a repo: cs, or with cs nil all its
|
||||
// plain ones.
|
||||
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
|
||||
p := m.providerFor(src)
|
||||
files, ok := p.(provider.Contents)
|
||||
if !ok {
|
||||
m.flash = warning("these files can't be shared")
|
||||
return nil
|
||||
}
|
||||
general, timeout := m.shareKey, m.timeout
|
||||
m.flash = flash{text: "reading the files…"}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
msg := shareFilesMsg{sharing: sharing{repo: src}}
|
||||
if cs == nil {
|
||||
all, err := p.Containers(ctx, src)
|
||||
if err != nil {
|
||||
msg.err = err
|
||||
return msg
|
||||
}
|
||||
for _, c := range all {
|
||||
if c.Detail == provider.DetailPlainEnv {
|
||||
cs = append(cs, c)
|
||||
}
|
||||
}
|
||||
if len(cs) == 0 {
|
||||
msg.err = errors.New(src.Name + " has no plain env files")
|
||||
return msg
|
||||
}
|
||||
}
|
||||
for _, c := range cs {
|
||||
b, err := files.Contents(ctx, c)
|
||||
if err != nil {
|
||||
msg.err = err
|
||||
return msg
|
||||
}
|
||||
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
|
||||
}
|
||||
key, ok, err := share.DefaultKey(src.ID, general)
|
||||
if ok {
|
||||
msg.key = key
|
||||
}
|
||||
msg.err = err
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
||||
case msg.key == (ref.Ref{}):
|
||||
return m.pickShareKey(msg.sharing)
|
||||
default:
|
||||
m.confirmShare(msg.sharing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shareTitle names the action: "Share .env", "Share 2 env files".
|
||||
func shareTitle(s sharing) string {
|
||||
if len(s.files) == 1 {
|
||||
return "Share " + path.Base(s.files[0].Path)
|
||||
}
|
||||
return "Share " + plural(len(s.files), "env file")
|
||||
}
|
||||
|
||||
// sharedFiles lists the files being shared, under what's happening.
|
||||
func (m *Model) sharedFiles(s sharing) []string {
|
||||
lines := []string{"Sharing " + plural(len(s.files), "env file") + " as an encrypted link:"}
|
||||
for _, f := range s.files {
|
||||
lines = append(lines, " "+m.repoLabel(s.repo.Name, f.Path))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// confirmShare says what the link will hold before making it.
|
||||
func (m *Model) confirmShare(s sharing) {
|
||||
lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
|
||||
m.showMenu(shareTitle(s), lines, []option{
|
||||
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s, nil) }},
|
||||
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
|
||||
})
|
||||
}
|
||||
|
||||
// pickShareKey asks which secret to encrypt with, in Go to, then shares
|
||||
// with it: the picker showed what's shared, so there's no asking again.
|
||||
// It stays open until the link is made, to say why a secret can't be
|
||||
// used.
|
||||
func (m *Model) pickShareKey(s sharing) tea.Cmd {
|
||||
cmd := m.openGoTo(tabSearch, true)
|
||||
p := &picker{
|
||||
title: shareTitle(s),
|
||||
lines: m.sharedFiles(s),
|
||||
heading: "Pick a secret to encrypt with",
|
||||
help: []string{
|
||||
m.st.Muted.Render("The recipient needs read access to it to decrypt the files."),
|
||||
m.st.Muted.Render("Its value isn't shown or put in the link."),
|
||||
},
|
||||
}
|
||||
p.pick = func(m *Model, key ref.Ref) tea.Cmd {
|
||||
s.key = key
|
||||
p.err, p.busy = "", "encrypting with "+key.Name+"…"
|
||||
cmd := m.seal(s, p)
|
||||
m.flash = flash{}
|
||||
return cmd
|
||||
}
|
||||
m.search.picker = p
|
||||
m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border
|
||||
m.fillSearch(false)
|
||||
return tea.Batch(cmd, m.searchLoads())
|
||||
}
|
||||
|
||||
// pickPasted picks a pasted reference to a secret, while picking one.
|
||||
func (m *Model) pickPasted(text string) tea.Cmd {
|
||||
key, err := share.ParseKey(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
m.search.picker.err = "✗ " + capitalize(err.Error())
|
||||
return nil
|
||||
}
|
||||
return m.search.picker.choose(m, key)
|
||||
}
|
||||
|
||||
// seal reads the key and encrypts the files with it, for the picker
|
||||
// from (nil: the key wasn't picked).
|
||||
func (m *Model) seal(s sharing, from *picker) tea.Cmd {
|
||||
read, rules := m.keyReader(), m.shareRules
|
||||
m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
v, version, err := read(s.key)
|
||||
if err != nil {
|
||||
return sealedMsg{from: from, err: &share.KeyError{Name: s.key.Name, Err: err}}
|
||||
}
|
||||
link, err := share.Seal(share.Header{Key: share.Pin(s.key, version), Repo: s.repo.Detail}, []byte(v.Reveal()), s.files, rules)
|
||||
return sealedMsg{link: link, n: len(s.files), from: from, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
|
||||
if p := msg.from; p != nil {
|
||||
if m.search == nil || m.search.picker != p {
|
||||
return nil // the picker was closed: cancelled
|
||||
}
|
||||
p.busy = ""
|
||||
var bad *share.KeyError
|
||||
if errors.As(msg.err, &bad) {
|
||||
p.err = "✗ " + capitalize(bad.Error()) // to pick another
|
||||
return nil
|
||||
}
|
||||
m.search = nil
|
||||
}
|
||||
if msg.err != nil {
|
||||
m.noteErr(msg.err)
|
||||
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.dismissedLink = msg.link.String() // not offered back on focus
|
||||
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
|
||||
}
|
||||
|
||||
// keyReader reads a key's value with the providers, the account that
|
||||
// last read it first, off the UI goroutine.
|
||||
func (m *Model) keyReader() func(ref.Ref) (provider.SecretValue, string, error) {
|
||||
providers, st, timeout := m.providers, m.store, m.timeout
|
||||
return func(key ref.Ref) (provider.SecretValue, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
k := key
|
||||
k.Version = ""
|
||||
node := st.Node(k.String())
|
||||
return ref.Read(ctx, providers, key, node.Account, node.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// --- opening ---
|
||||
|
||||
type shareOpenedMsg struct {
|
||||
sealed share.Sealed
|
||||
files []share.File
|
||||
dir string // the repo's clone; "" when it isn't here
|
||||
err error
|
||||
}
|
||||
|
||||
// offerShare says what a share link holds before decrypting it: links
|
||||
// can come from anywhere, so nothing is read or written unasked.
|
||||
func (m *Model) offerShare(r ref.Ref) tea.Cmd {
|
||||
s, err := share.Read(r)
|
||||
if err != nil {
|
||||
m.showMenu("Share link can't be read", []string{m.st.Error.Render(err.Error())}, nil)
|
||||
return nil
|
||||
}
|
||||
from := ""
|
||||
if s.Repo != "" {
|
||||
from = m.st.Muted.Render(" from ") + m.repoLabel(s.Repo)
|
||||
}
|
||||
m.showMenu("Shared env files", []string{
|
||||
m.st.Muted.Render(plural(s.Files, "env file")) + from,
|
||||
m.st.Muted.Render("encrypted with ") + m.refLabel(s.Key),
|
||||
}, []option{
|
||||
{key: "o", label: "Decrypt…", tip: "reads the secret, then asks before saving", run: func(m *Model) tea.Cmd { return m.openShare(s) }},
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// openShare reads the key, decrypts the files and finds the repo's clone.
|
||||
func (m *Model) openShare(s share.Sealed) tea.Cmd {
|
||||
read := m.keyReader()
|
||||
providers, timeout := m.providers, m.timeout
|
||||
m.flash = flash{text: "decrypting with " + s.Key.Name + "…"}
|
||||
return func() tea.Msg {
|
||||
msg := shareOpenedMsg{sealed: s}
|
||||
v, _, err := read(s.Key)
|
||||
if err != nil {
|
||||
msg.err = &share.KeyError{Name: s.Key.Name, Err: err}
|
||||
return msg
|
||||
}
|
||||
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
|
||||
return msg
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
for _, p := range providers {
|
||||
if loc, ok := p.(provider.Locator); ok && p.Kind() == provider.KindRepo && s.Repo != "" {
|
||||
// Its clone, if it's on disk (the demo's aren't).
|
||||
if root, found, _ := loc.Locate(ctx, s.Repo); found && isDir(root) {
|
||||
msg.dir = root
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
|
||||
switch {
|
||||
case msg.err != nil:
|
||||
m.noteErr(msg.err)
|
||||
m.openFailed(msg.sealed, msg.err, false)
|
||||
case msg.dir == "":
|
||||
return m.askShareDir(msg)
|
||||
default:
|
||||
targets, err := planSave(msg.files, msg.dir)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.offerSave(targets, msg.dir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openFailed says why a share link couldn't be decrypted, in a popup
|
||||
// where its offer was: its first line (wrapped), or with whole all of it
|
||||
// (Key Vault's refusals say what to check on later lines). From there,
|
||||
// the key's secret can be gone to: its version, which may be disabled.
|
||||
func (m *Model) openFailed(s share.Sealed, err error, whole bool) {
|
||||
width := max(30, min(m.w-12, 76))
|
||||
text := capitalize(err.Error())
|
||||
first, rest, _ := strings.Cut(text, "\n")
|
||||
cut := !whole && rest != "" // more than the reason: what to check
|
||||
if !whole {
|
||||
text = first
|
||||
}
|
||||
var lines []string
|
||||
for i, l := range strings.Split(ansi.Wordwrap(text, width-2, " "), "\n") {
|
||||
if i == 0 {
|
||||
l = "✗ " + l
|
||||
} else if l != "" {
|
||||
l = " " + l
|
||||
}
|
||||
lines = append(lines, m.st.Error.Render(l))
|
||||
}
|
||||
var opts []option
|
||||
if cut {
|
||||
opts = append(opts, option{key: "r", label: "Show the whole message", run: func(m *Model) tea.Cmd {
|
||||
m.openFailed(s, err, true)
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
opts = append(opts, option{key: "g", label: "Go to " + s.Key.Name, run: func(m *Model) tea.Cmd { return m.openRef(s.Key, actGo) }})
|
||||
m.showMenu("Can't decrypt the shared files", lines, opts)
|
||||
}
|
||||
|
||||
// askShareDir asks where to save files from a repo that isn't here. It
|
||||
// stays until they're saved, saying why when they can't be.
|
||||
func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
|
||||
in := textinput.New()
|
||||
in.Prompt = "folder: "
|
||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
||||
dir := m.placeDir
|
||||
if dir == "" {
|
||||
dir, _ = os.Getwd()
|
||||
}
|
||||
in.SetValue(dir)
|
||||
in.CursorEnd()
|
||||
lines := []string{m.st.Muted.Render("Paths are from the repo's root.")}
|
||||
if msg.sealed.Repo != "" {
|
||||
lines = append([]string{m.repoLabel(msg.sealed.Repo) + m.st.Muted.Render(" isn't among your repos. Save where?")}, lines...)
|
||||
}
|
||||
p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"}
|
||||
p.submit = func(m *Model, text string) tea.Cmd {
|
||||
dir := expandHome(strings.TrimSpace(text))
|
||||
targets, err := planSave(msg.files, dir)
|
||||
switch {
|
||||
case err == nil && slices.ContainsFunc(targets, func(t share.Target) bool { return t.Exists }):
|
||||
m.offerSave(targets, dir) // ask before replacing
|
||||
return nil
|
||||
case err == nil:
|
||||
// Chosen, with nothing to replace: saved at once.
|
||||
if err = m.writeShared(targets, dir, false); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
p.err = err.Error()
|
||||
m.prompt = p // again, to fix the folder
|
||||
return p.input.Focus()
|
||||
}
|
||||
m.prompt = p
|
||||
return p.input.Focus()
|
||||
}
|
||||
|
||||
// planSave says where each file goes in dir, which must be a folder.
|
||||
func planSave(files []share.File, dir string) ([]share.Target, error) {
|
||||
if !isDir(dir) {
|
||||
return nil, fmt.Errorf("no folder %s", dir)
|
||||
}
|
||||
return share.Plan(dir, files)
|
||||
}
|
||||
|
||||
// offerSave shows where each file goes, then saves them as chosen:
|
||||
// files there already are replaced only when asked.
|
||||
func (m *Model) offerSave(targets []share.Target, dir string) {
|
||||
var lines []string
|
||||
exist := 0
|
||||
for _, t := range targets {
|
||||
file := " " + m.repoLabel(filepath.Base(dir), t.Path)
|
||||
line := file + m.st.Muted.Render(" · new")
|
||||
if t.Exists {
|
||||
exist++
|
||||
line = file + m.st.Muted.Render(" · replaces the one there")
|
||||
}
|
||||
if t.Exposed {
|
||||
line += m.st.Error.Render(" · not ignored by git")
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = append([]string{m.st.Muted.Render("In " + dir + ":")}, lines...)
|
||||
write := func(onlyNew bool) func(*Model) tea.Cmd {
|
||||
return func(m *Model) tea.Cmd {
|
||||
if err := m.writeShared(targets, dir, onlyNew); err != nil {
|
||||
m.flash = flash{text: "save: " + err.Error(), isErr: true}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var opts []option
|
||||
switch {
|
||||
case exist == 0:
|
||||
opts = append(opts, option{key: "w", label: "Save", run: write(false)})
|
||||
case exist == len(targets):
|
||||
opts = append(opts, option{key: "w", label: "Replace", run: write(false)})
|
||||
default:
|
||||
opts = append(opts,
|
||||
option{key: "w", label: fmt.Sprintf("Save all, replacing %d", exist), run: write(false)},
|
||||
option{key: "n", label: onlyNew(len(targets) - exist), run: write(true)},
|
||||
)
|
||||
}
|
||||
m.showMenu("Save the shared files", lines, opts)
|
||||
}
|
||||
|
||||
// writeShared writes the files (with onlyNew, those not there yet) and
|
||||
// says where, and which ones git doesn't ignore.
|
||||
func (m *Model) writeShared(targets []share.Target, dir string, onlyNew bool) error {
|
||||
n := 0
|
||||
var exposed []string
|
||||
for _, t := range targets {
|
||||
if onlyNew && t.Exists {
|
||||
continue
|
||||
}
|
||||
if err := t.Write(); err != nil {
|
||||
return fmt.Errorf("%s: %w", t.Path, err)
|
||||
}
|
||||
n++
|
||||
if t.Exposed {
|
||||
exposed = append(exposed, t.Path)
|
||||
}
|
||||
}
|
||||
m.flash = done("saved " + plural(n, "file") + " in " + dir)
|
||||
if len(exposed) > 0 {
|
||||
m.flash = warning("saved " + plural(n, "file") + " in " + dir + "; git doesn't ignore " + strings.Join(exposed, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDir reports whether dir is a folder on disk.
|
||||
func isDir(dir string) bool {
|
||||
info, err := os.Stat(dir)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// expandHome expands a leading ~/ to the home folder.
|
||||
func expandHome(dir string) string {
|
||||
if rest, ok := strings.CutPrefix(dir, "~/"); ok {
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(home, rest)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// onlyNew labels saving only the n files not there yet.
|
||||
func onlyNew(n int) string {
|
||||
if n == 1 {
|
||||
return "Save only the new one"
|
||||
}
|
||||
return fmt.Sprintf("Save only the %d new ones", n)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"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/share"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
||||
)
|
||||
|
||||
// scryptWait is how long the driver waits for encrypting and decrypting:
|
||||
// tens of milliseconds, more than its usual wait, and more under load.
|
||||
const scryptWait = 20 * time.Second
|
||||
|
||||
// demoRules take the demo key, made up to be remembered.
|
||||
var demoRules = share.Rules{MinLength: 16, MinClasses: 1}
|
||||
|
||||
// cloneWith makes a clone of git.example.com/team/app holding files.
|
||||
func cloneWith(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
root := gitRepoWith(t, "https://git.example.com/team/app.git")
|
||||
os.WriteFile(filepath.Join(root, ".gitignore"), []byte(".env\n"), 0o644)
|
||||
for name, data := range files {
|
||||
os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0o755)
|
||||
os.WriteFile(filepath.Join(root, name), []byte(data), 0o600)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// shareFrom shares a repo's env files from lazysecrets started in it,
|
||||
// returning the link copied.
|
||||
func shareFrom(t *testing.T, root string, paste bool) string {
|
||||
t.Helper()
|
||||
clip := &fakeClipboard{}
|
||||
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||
d.wait = scryptWait
|
||||
if src, ok := d.m.selectedSource(); !ok || src.ID != root {
|
||||
t.Fatalf("selected %v, want the repo", src.ID)
|
||||
}
|
||||
d.keys("S", "f")
|
||||
// No share.key set: it asks which secret, in Go to, which lists
|
||||
// only secrets.
|
||||
v := d.m.search
|
||||
if v == nil || v.picker == nil {
|
||||
t.Fatalf("no secret picker: %s", d.m.flash.text)
|
||||
}
|
||||
for _, o := range v.shown {
|
||||
if o.plain || strings.Contains(o.label, ".env") {
|
||||
t.Errorf("a plain env value offered as the key: %s", o.label)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(d.screen(), "as an encrypted link") {
|
||||
t.Errorf("doesn't say what picking does:\n%s", d.screen())
|
||||
}
|
||||
if paste {
|
||||
// A reference pasted is picked as it is.
|
||||
d.send(tea.PasteMsg{Content: "https://" + kv + "/secrets/db-password"})
|
||||
} else {
|
||||
for _, r := range "app-prod db-password" {
|
||||
d.keys(string(r))
|
||||
}
|
||||
if len(v.shown) == 0 {
|
||||
t.Fatalf("no secrets to pick:\n%s", d.screen())
|
||||
}
|
||||
for _, o := range v.shown {
|
||||
if _, ok := pickable(o); !ok {
|
||||
t.Errorf("not a secret: %s", o.label)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(d.screen(), "Share 2 env files") && !strings.Contains(d.screen(), "Share .env") {
|
||||
t.Errorf("not titled as a picker:\n%s", d.screen())
|
||||
}
|
||||
d.keys("enter")
|
||||
}
|
||||
// Picking it shares: the picker said what.
|
||||
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
|
||||
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
|
||||
}
|
||||
return clip.content
|
||||
}
|
||||
|
||||
func TestShareFilesRoundTrip(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
link := shareFrom(t, cloneWith(t, map[string]string{
|
||||
".env": "DB_PASSWORD=hunter2\n",
|
||||
"prod.env": "TOKEN=abc\n",
|
||||
}), false)
|
||||
|
||||
// A colleague's clone: .env is there already, prod.env isn't.
|
||||
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
|
||||
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: theirs}))
|
||||
d.wait = scryptWait
|
||||
d.send(tea.PasteMsg{Content: link})
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Shared env files" || !strings.Contains(d.screen(), "2 env files from git.example.com/team/app") {
|
||||
t.Fatalf("not offered:\n%s", d.screen())
|
||||
}
|
||||
d.keys("o")
|
||||
if d.m.prompt == nil || d.m.prompt.title != "Save the shared files" {
|
||||
t.Fatalf("no save menu (%s)", d.m.flash.text)
|
||||
}
|
||||
if s := d.screen(); !strings.Contains(s, "replaces the one there") || !strings.Contains(s, "not ignored by git") {
|
||||
t.Errorf("save menu doesn't say what happens:\n%s", s)
|
||||
}
|
||||
d.keys("n") // only the new one
|
||||
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=old\n" {
|
||||
t.Errorf(".env replaced: %q", b)
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(theirs, "prod.env")); string(b) != "TOKEN=abc\n" {
|
||||
t.Errorf("prod.env: %q", b)
|
||||
}
|
||||
|
||||
// Again, replacing.
|
||||
d.send(tea.PasteMsg{Content: link})
|
||||
d.keys("o", "w")
|
||||
if b, _ := os.ReadFile(filepath.Join(theirs, ".env")); string(b) != "DB_PASSWORD=hunter2\n" {
|
||||
t.Errorf(".env not replaced: %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareWithShareKeyConfirms(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root := cloneWith(t, map[string]string{
|
||||
".env": "A=1\n",
|
||||
".lazysecrets.yaml": "share:\n key: https://" + kv + "/secrets/db-password\n",
|
||||
})
|
||||
clip := &fakeClipboard{}
|
||||
d := newDriverOpts(t, Options{Clipboard: clip, ShareRules: demoRules}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||
d.wait = scryptWait
|
||||
d.keys("S", "f")
|
||||
// The key wasn't picked: it shows which, before sharing.
|
||||
if d.m.search != nil || d.m.prompt == nil || d.m.prompt.title != "Share .env" || !strings.Contains(d.screen(), "db-password") {
|
||||
t.Fatalf("no confirmation:\n%s", d.screen())
|
||||
}
|
||||
d.keys("c")
|
||||
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
|
||||
t.Errorf("copied %q (%s)", clip.content, d.m.flash.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLinkElsewhereAsksWhere(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}), true)
|
||||
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
|
||||
d.wait = scryptWait
|
||||
d.send(tea.PasteMsg{Content: link})
|
||||
d.keys("o")
|
||||
if d.m.prompt == nil || d.m.prompt.input == nil || d.m.prompt.input.Value() != dir {
|
||||
t.Fatalf("doesn't ask where, starting from where it runs:\n%s", d.screen())
|
||||
}
|
||||
// A folder that isn't there: the prompt stays, saying so.
|
||||
d.m.prompt.input.SetValue(filepath.Join(dir, "nope"))
|
||||
d.keys("enter")
|
||||
if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "✗ No folder") {
|
||||
t.Fatalf("a missing folder isn't said in the prompt:\n%s", d.screen())
|
||||
}
|
||||
if !strings.Contains(d.screen(), "enter save") {
|
||||
t.Errorf("the footer doesn't say enter saves:\n%s", d.screen())
|
||||
}
|
||||
d.m.prompt.input.SetValue(dir)
|
||||
d.keys("enter") // saved at once: nothing there to replace
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "A=1\n" {
|
||||
t.Errorf(".env: %q (%s)", b, d.m.flash.text)
|
||||
}
|
||||
if d.m.prompt != nil || !strings.Contains(d.m.flash.text, "saved 1 file in "+dir) {
|
||||
t.Errorf("not closed with where it saved: %q", d.m.flash.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareLinkGarbled(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriver(t)
|
||||
d.send(tea.PasteMsg{Content: "lazysecrets://share/AQ" + strings.Repeat("A", 30)})
|
||||
if d.m.prompt == nil || !strings.Contains(d.screen(), "garbled") {
|
||||
t.Errorf("not reported:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
// TestShareInDemo shares a demo repo's env files with the demo key and
|
||||
// opens the link, as lazysecrets --demo does: the demo repos aren't on
|
||||
// disk, so it asks where to save.
|
||||
func TestShareInDemo(t *testing.T) {
|
||||
clip := &fakeClipboard{}
|
||||
d := newDriverOpts(t, Options{Clipboard: clip},
|
||||
fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos())
|
||||
d.wait = scryptWait
|
||||
var src provider.Source
|
||||
for _, s := range d.m.sourceResults["Repos"].sources {
|
||||
if s.Here {
|
||||
src = s
|
||||
}
|
||||
}
|
||||
if src.ID == "" {
|
||||
t.Fatal("no demo repo")
|
||||
}
|
||||
src.Provider = "Repos"
|
||||
d.run(d.m.shareFiles(src, nil))
|
||||
if d.m.search == nil || d.m.search.picker == nil {
|
||||
t.Fatalf("no picker: %s", d.m.flash.text)
|
||||
}
|
||||
// Too short a key: the picker stays, saying why.
|
||||
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"})
|
||||
if d.m.search == nil || !strings.Contains(d.m.search.picker.err, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") {
|
||||
t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
|
||||
}
|
||||
if !strings.Contains(d.screen(), "28 characters") {
|
||||
t.Errorf("not shown:\n%s", d.screen())
|
||||
}
|
||||
d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"})
|
||||
if !strings.HasPrefix(clip.content, "lazysecrets://share/") || d.m.search != nil {
|
||||
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
|
||||
}
|
||||
link := clip.content
|
||||
// A value in a demo repo's SOPS file can be the key too, found by
|
||||
// the repo's remote.
|
||||
clip.content = ""
|
||||
d.run(d.m.shareFiles(src, nil))
|
||||
d.send(tea.PasteMsg{Content: "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/config%2Foauth.enc.json/github.client_secret"})
|
||||
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
|
||||
t.Errorf("with the demo repo's key: copied %q (%s)", clip.content, d.m.flash.text)
|
||||
}
|
||||
|
||||
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
d.m.placeDir = dir
|
||||
d.send(tea.PasteMsg{Content: link})
|
||||
d.keys("o")
|
||||
if d.m.prompt == nil || d.m.prompt.input == nil || !strings.Contains(d.screen(), "isn't among your repos") {
|
||||
t.Fatalf("doesn't ask where:\n%s", d.screen())
|
||||
}
|
||||
d.keys("enter") // saved at once: nothing there to replace
|
||||
entries, _ := os.ReadDir(dir)
|
||||
if len(entries) == 0 {
|
||||
t.Fatalf("nothing saved (%s)", d.m.flash.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickerCancelAndBusy(t *testing.T) {
|
||||
clip := &fakeClipboard{}
|
||||
d := newDriverOpts(t, Options{Clipboard: clip},
|
||||
fake.Azure(0), fake.AzureOps(0).As("Azure:admin", "Azure · admin"), fake.Repos())
|
||||
var src provider.Source
|
||||
for _, s := range d.m.sourceResults["Repos"].sources {
|
||||
if s.Here {
|
||||
src = s
|
||||
}
|
||||
}
|
||||
src.Provider = "Repos"
|
||||
d.run(d.m.shareFiles(src, nil))
|
||||
p := d.m.search.picker
|
||||
key, _ := share.ParseKey("https://kv-shared-prod.vault.azure.net/secrets/env-share")
|
||||
|
||||
// Picking starts sealing; another pick meanwhile is ignored.
|
||||
cmd := p.choose(&d.m, key)
|
||||
if cmd == nil || p.busy == "" {
|
||||
t.Fatal("picking didn't start")
|
||||
}
|
||||
if again := p.choose(&d.m, key); again != nil {
|
||||
t.Error("a second pick while busy wasn't ignored")
|
||||
}
|
||||
|
||||
// Closed before the link is made: cancelled, nothing copied.
|
||||
d.m.search = nil
|
||||
d.wait = scryptWait
|
||||
d.run(cmd)
|
||||
if clip.content != "" {
|
||||
t.Errorf("copied after the picker was closed: %q", clip.content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainEnvNotPickableFromFavourites(t *testing.T) {
|
||||
mem, _ := store.Load("")
|
||||
d := newDriverOpts(t, Options{Store: mem}, fake.Azure(0), fake.Repos())
|
||||
var src provider.Source
|
||||
for _, s := range d.m.sourceResults["Repos"].sources {
|
||||
if s.Here {
|
||||
src = s
|
||||
}
|
||||
}
|
||||
// A key in the demo repo's plain .env, starred.
|
||||
key := "lazysecrets://repo/" + url.PathEscape(src.Detail) + "/.env/DATABASE_URL"
|
||||
mem.Update(key, func(n *store.Node) {
|
||||
n.StarredBy = []string{"Repos"}
|
||||
n.Path = []string{src.Name, ".env"}
|
||||
n.Source = src.ID
|
||||
})
|
||||
src.Provider = "Repos"
|
||||
d.run(d.m.shareFiles(src, nil))
|
||||
favs, _ := d.m.storeEntries()
|
||||
for _, o := range favs {
|
||||
if o.star == key {
|
||||
if _, ok := pickable(o); ok {
|
||||
t.Error("a plain .env value is offered as a key in Favourites")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("the starred key isn't among the favourites: %d", len(favs))
|
||||
}
|
||||
|
||||
// refusing is a provider whose values can't be read, like a disabled
|
||||
// version in Key Vault.
|
||||
type refusing struct{ provider.Provider }
|
||||
|
||||
func (refusing) Value(context.Context, provider.Object) (provider.SecretValue, error) {
|
||||
return provider.SecretValue{}, &provider.Denied{
|
||||
Err: errors.New("Operation get is not allowed on a disabled secret."),
|
||||
Checks: []string{"the secret's version is enabled"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenFailsInAPopup(t *testing.T) {
|
||||
key, _ := share.ParseKey("https://" + kv + "/secrets/db-password")
|
||||
link, err := share.Seal(share.Header{Key: key, Repo: "git.example.com/team/app"},
|
||||
[]byte("correct-horse-battery-staple"), []share.File{{Path: ".env", Data: []byte("A=1\n")}}, demoRules)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := newDriverOpts(t, Options{}, refusing{fake.Azure(0)})
|
||||
d.wait = scryptWait
|
||||
d.send(tea.PasteMsg{Content: link.String()})
|
||||
d.keys("o")
|
||||
p := d.m.prompt
|
||||
if p == nil || p.title != "Can't decrypt the shared files" || !strings.Contains(d.screen(), "Can't use db-password: Operation get is not allowed") {
|
||||
t.Fatalf("not in a popup:\n%s", d.screen())
|
||||
}
|
||||
if strings.Contains(d.screen(), "version is enabled") {
|
||||
t.Error("the whole message is shown before asking")
|
||||
}
|
||||
d.keys("r")
|
||||
if !strings.Contains(d.screen(), "version is enabled") {
|
||||
t.Errorf("r doesn't show the whole message:\n%s", d.screen())
|
||||
}
|
||||
d.keys("g")
|
||||
if _, _, sel := d.selected(); d.m.prompt != nil || (sel != "db-password" && (d.m.nav == nil || d.m.nav.ref.Name != "db-password")) {
|
||||
t.Errorf("g doesn't go to the key: %q, %q\n%s", sel, d.m.flash.text, d.screen())
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func (m *Map) Actions() []Action {
|
||||
a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
||||
|
||||
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, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."),
|
||||
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."),
|
||||
|
||||
@@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) {
|
||||
d.container = &s.Container
|
||||
}
|
||||
|
||||
// Refresh shows s's newer metadata if s is the secret shown, keeping
|
||||
// its account and any value shown or loading.
|
||||
func (d *Detail) Refresh(s provider.Object) {
|
||||
if d.secret == nil || d.secret.ID != s.ID {
|
||||
return
|
||||
}
|
||||
s.Container = d.secret.Container
|
||||
d.secret = &s
|
||||
}
|
||||
|
||||
// ShowContainer shows a container summary when no secret is selected.
|
||||
func (d *Detail) ShowContainer(c provider.Container) {
|
||||
d.Clear()
|
||||
|
||||
Reference in new issue
Block a user