feat: share env files as encrypted links #54

Merged
keb merged 25 commits from share-files into main 2026-10-13 14:55:22 +02:00
9 changed files with 218 additions and 118 deletions
Showing only changes of commit 61de5e616d - Show all commits

No files matched your search

+3 -3
View File
@@ -14,7 +14,7 @@ import (
"go.yaml.in/yaml/v3" "go.yaml.in/yaml/v3"
"git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/ref" "git.kebcloud.no/keb/lazysecrets/internal/share"
"git.kebcloud.no/keb/lazysecrets/internal/update" "git.kebcloud.no/keb/lazysecrets/internal/update"
) )
@@ -242,8 +242,8 @@ func (c Config) validate(path string) error {
} }
} }
if k := c.Share.Key; k != "" { if k := c.Share.Key; k != "" {
if r, err := ref.Parse(k); err != nil || r.IsShare() || r.IsSource() || r.IsContainer() { if _, err := share.ParseKey(k); err != nil {
return fmt.Errorf("%s: share.key: %q is not a secret's lazysecrets:// link, Key Vault URL or reference", path, k) return fmt.Errorf("%s: share.key: %w", path, err)
} }
} }
if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 { if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 {
+1
View File
@@ -73,6 +73,7 @@ func TestLoadRejectsMistakes(t *testing.T) {
"releases": "releases: [git.example.com/team/lazysecrets]\n", "releases": "releases: [git.example.com/team/lazysecrets]\n",
"http": "releases: [http://git.example.com/team/lazysecrets]\n", "http": "releases: [http://git.example.com/team/lazysecrets]\n",
"share key": "share:\n key: https://kv.vault.azure.net/\n", "share key": "share:\n key: https://kv.vault.azure.net/\n",
"workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n",
"short keys": "share:\n keyRules:\n minLength: 8\n", "short keys": "share:\n keyRules:\n minLength: 8\n",
"classes": "share:\n keyRules:\n minClasses: 5\n", "classes": "share:\n keyRules:\n minClasses: 5\n",
} { } {
-10
View File
@@ -67,13 +67,3 @@ func (r Rules) withDefaults() Rules {
r.MinLength = max(r.MinLength, MinKey) r.MinLength = max(r.MinLength, MinKey)
return r return r
} }
// KeyError is a key that can't be used: it can't be read, or doesn't
// keep to the rules.
type KeyError struct {
Name string // the secret's
Err error
}
func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() }
func (e *KeyError) Unwrap() error { return e.Err }
+14 -4
View File
@@ -47,8 +47,8 @@ const (
MaxLink = 16 << 10 MaxLink = 16 << 10
// MinKey is the shortest key the rules can ask for. // MinKey is the shortest key the rules can ask for.
MinKey = 16 MinKey = 16
// maxFiles caps what opening a link unpacks. // maxUnpacked caps the bytes opening a link unpacks.
maxFiles = 1 << 20 maxUnpacked = 1 << 20
) )
// workFactor is the scrypt work factor for sealing: 2^15, about 40 ms // workFactor is the scrypt work factor for sealing: 2^15, about 40 ms
@@ -153,6 +153,16 @@ func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) {
return r, nil return r, nil
} }
// KeyError is a key that can't be used: it can't be read, or doesn't
// keep to the rules.
type KeyError struct {
Name string // the secret's
Err error
}
func (e *KeyError) Error() string { return "can't use " + e.Name + ": " + e.Err.Error() }
func (e *KeyError) Unwrap() error { return e.Err }
// Read reads a share link's header. // Read reads a share link's header.
func Read(r ref.Ref) (Sealed, error) { func Read(r ref.Ref) (Sealed, error) {
bad := errors.New("share link is cut short or garbled: copy all of it") bad := errors.New("share link is cut short or garbled: copy all of it")
@@ -197,11 +207,11 @@ func (s Sealed) Open(key []byte) ([]File, error) {
} }
return nil, fmt.Errorf("can't decrypt the link: %w", err) return nil, fmt.Errorf("can't decrypt the link: %w", err)
} }
b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxFiles+1)) b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxUnpacked+1))
if err != nil { if err != nil {
return nil, fmt.Errorf("can't decrypt the link: %w", err) return nil, fmt.Errorf("can't decrypt the link: %w", err)
} }
if len(b) > maxFiles { if len(b) > maxUnpacked {
return nil, errors.New("the link holds more than lazysecrets unpacks") return nil, errors.New("the link holds more than lazysecrets unpacks")
} }
var plain []file var plain []file
+19
View File
@@ -47,6 +47,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
out = append(out, entry{depth, sort, option{ out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ", label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct), id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
plain: m.inPlainEnv(r),
run: func(m *Model) tea.Cmd { run: func(m *Model) tea.Cmd {
m.used(key) m.used(key)
return run(m) return run(m)
@@ -138,6 +139,24 @@ func (m Model) pathLabel(kind provider.Kind, path []string, account string) stri
return label 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 // repoLabel draws a repo, or a file in it, as everywhere else: the repo
// icon and colour, "app / apps/api/.env". // icon and colour, "app / apps/api/.env".
func (m Model) repoLabel(repo string, file ...string) string { func (m Model) repoLabel(repo string, file ...string) string {
+1 -1
View File
@@ -818,7 +818,7 @@ func (m *Model) openText(s string) tea.Cmd {
// handlePaste opens a pasted reference, unless a text input has focus. // handlePaste opens a pasted reference, unless a text input has focus.
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd { func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
if m.search != nil && m.search.pick != nil { if m.search != nil && m.search.picker != nil {
return m.pickPasted(msg.Content) // a reference to the secret return m.pickPasted(msg.Content) // a reference to the secret
} }
if p := m.prompt; p != nil && p.input != nil { if p := m.prompt; p != nil && p.input != nil {
+42 -30
View File
@@ -53,16 +53,8 @@ type searchView struct {
res int // cursor in shown res int // cursor in shown
here string // the scope of the source Go to was opened from, if any here string // the scope of the source Go to was opened from, if any
// pick, if set, makes Go to a picker of secrets, for title: it lists // picker, if set, makes Go to pick a secret instead of going to it.
// secrets only, and enter gives the selected one's link to pick picker *picker
// instead of going there.
pick func(m *Model, r ref.Ref) tea.Cmd
pickTitle string // the popup's
pickLines []string // what picking is for, above the picker
pickHeading string // the picker's title, on its border
pickHelp []string // inside it, above the tabs
pickBusy string // what picking is doing, under the help
pickErr string // why the key picked can't be used
// loaded are the scopes a search has loaded since the popup opened: // 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 // typing loads the scope once, so lists that fail aren't asked for
@@ -279,7 +271,7 @@ func (v *searchView) match() []option {
} }
} }
} }
if v.pick != nil { if v.picker != nil {
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok }) cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
} }
q := v.input.Value() q := v.input.Value()
@@ -438,13 +430,13 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
return nil return nil
case key.Matches(msg, k.sel): case key.Matches(msg, k.sel):
switch { switch {
case v.results && v.res < len(v.shown) && v.pick != nil: case v.results && v.res < len(v.shown) && v.picker != nil:
r, _ := pickable(v.shown[v.res]) r, _ := pickable(v.shown[v.res])
return v.pick(m, r) // the picker closes when it's done return v.picker.choose(m, r)
case v.results && v.res < len(v.shown): case v.results && v.res < len(v.shown):
m.search = nil m.search = nil
return v.shown[v.res].run(m) return v.shown[v.res].run(m)
case !v.results && v.pick != nil: case !v.results && v.picker != nil:
v.results = true // a picker only narrows to the scope v.results = true // a picker only narrows to the scope
case !v.results && v.onSource(): case !v.results && v.onSource():
m.search = nil m.search = nil
@@ -662,15 +654,14 @@ func (m Model) searchViewBox() string {
focused := !m.termBlurred focused := !m.termBlurred
w := searchWidth(m.w) w := searchWidth(m.w)
popupW := w popupW := w
if v.pick != nil { if v.picker != nil {
w -= 4 // inside the picker's border, in the popup w -= 4 // inside the picker's border, in the popup
} }
listH := max(5, min(m.h-10, 14)) listH := max(5, min(m.h-10, 14))
if v.pick != nil { if v.picker != nil {
// What it's for, the help, the tabs and a border more.
// What it's for, the help, a line or two on the key picked, the // What it's for, the help, a line or two on the key picked, the
// tabs and a border more. // tabs and a border more.
listH = max(5, min(m.h-10-len(v.pickLines)-len(v.pickHelp)-8, 14)) listH = max(5, min(m.h-10-len(v.picker.lines)-len(v.picker.help)-8, 14))
} }
srcW := 10 srcW := 10
@@ -765,16 +756,16 @@ func (m Model) searchViewBox() string {
ts := panes.TitleStyle(m.st, focused) ts := panes.TitleStyle(m.st, focused)
var body []string var body []string
if v.pick != nil { if p := v.picker; p != nil {
for _, l := range v.pickHelp { for _, l := range p.help {
body = append(body, " "+l) body = append(body, " "+l)
} }
switch { switch {
case v.pickBusy != "": case p.busy != "":
body = append(body, "", " "+m.st.Notice.Render(v.pickBusy)) body = append(body, "", " "+m.st.Notice.Render(p.busy))
case v.pickErr != "": case p.err != "":
body = append(body, "") body = append(body, "")
for _, l := range strings.Split(ansi.Wordwrap(v.pickErr, w-4, " "), "\n") { for _, l := range strings.Split(ansi.Wordwrap(p.err, w-4, " "), "\n") {
body = append(body, " "+m.st.Error.Render(l)) body = append(body, " "+m.st.Error.Render(l))
} }
} }
@@ -810,25 +801,46 @@ func (m Model) searchViewBox() string {
} }
// Tabs in the title, the current one underlined, like the panes; a // Tabs in the title, the current one underlined, like the panes; a
// picker has its own title, and the tabs over the input. // picker has its own title, and the tabs over the input.
head := ts.Render(goToTitle+" ") + m.goToTabs(ts) if v.picker == nil {
if v.pick == nil { head := ts.Render(goToTitle+" ") + m.goToTabs(ts)
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused) return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
} }
// A picker: in its own border, under what it's for. // A picker: in its own border, under what it's for.
var outer []string var outer []string
for _, l := range v.pickLines { for _, l := range v.picker.lines {
outer = append(outer, " "+l) outer = append(outer, " "+l)
} }
outer = append(outer, "") outer = append(outer, "")
for _, l := range strings.Split(panes.Box(m.st, ts.Render(v.pickHeading), "", body, w, len(body)+2, focused), "\n") { 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) outer = append(outer, " "+l)
} }
return panes.Box(m.st, ts.Render(v.pickTitle), foot, outer, popupW, len(outer)+2, focused) 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. // selectLabel names what enter does with the selected entry.
func (v *searchView) selectLabel() string { func (v *searchView) selectLabel() string {
if v.pick != nil { if v.picker != nil {
return "pick" return "pick"
} }
return "go" return "go"
+71 -67
View File
@@ -7,6 +7,7 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"charm.land/bubbles/v2/textinput" "charm.land/bubbles/v2/textinput"
@@ -36,6 +37,8 @@ type shareFilesMsg struct {
type sealedMsg struct { type sealedMsg struct {
link ref.Ref link ref.Ref
n int n int
// from is the picker the key was picked in; nil for share.key's.
from *picker
err error err error
} }
@@ -127,32 +130,34 @@ func (m *Model) sharedFiles(s sharing) []string {
func (m *Model) confirmShare(s sharing) { func (m *Model) confirmShare(s sharing) {
lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key)) lines := append(m.sharedFiles(s), "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
m.showMenu(shareTitle(s), lines, []option{ m.showMenu(shareTitle(s), lines, []option{
{key: "c", label: "Copy the link", run: func(m *Model) tea.Cmd { return m.seal(s) }}, {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) }}, {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 // pickShareKey asks which secret to encrypt with, in Go to, then shares
// with it. // 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 { func (m *Model) pickShareKey(s sharing) tea.Cmd {
cmd := m.openGoTo(tabSearch, true) cmd := m.openGoTo(tabSearch, true)
m.search.pick = func(m *Model, key ref.Ref) tea.Cmd { p := &picker{
// The picker showed what's shared: no need to ask again. It title: shareTitle(s),
// stays open until the link is made, to say why a key can't be lines: m.sharedFiles(s),
// used. 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 s.key = key
m.search.pickErr, m.search.pickBusy = "", "encrypting with "+key.Name+"…" p.err, p.busy = "", "encrypting with "+key.Name+"…"
cmd := m.seal(s) cmd := m.seal(s, p)
m.flash = flash{} m.flash = flash{}
return cmd return cmd
} }
m.search.pickTitle = shareTitle(s) m.search.picker = p
m.search.pickLines = m.sharedFiles(s)
m.search.pickHeading = "Pick a secret to encrypt with"
m.search.pickHelp = []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."),
}
m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border
m.fillSearch(false) m.fillSearch(false)
return tea.Batch(cmd, m.searchLoads()) return tea.Batch(cmd, m.searchLoads())
@@ -162,45 +167,45 @@ func (m *Model) pickShareKey(s sharing) tea.Cmd {
func (m *Model) pickPasted(text string) tea.Cmd { func (m *Model) pickPasted(text string) tea.Cmd {
key, err := share.ParseKey(strings.TrimSpace(text)) key, err := share.ParseKey(strings.TrimSpace(text))
if err != nil { if err != nil {
m.flash = flash{text: "share: " + err.Error(), isErr: true} m.search.picker.err = "✗ " + capitalize(err.Error())
return nil return nil
} }
return m.search.pick(m, key) return m.search.picker.choose(m, key)
} }
// seal reads the key and encrypts the files with it. // seal reads the key and encrypts the files with it, for the picker
func (m *Model) seal(s sharing) tea.Cmd { // from (nil: the key wasn't picked).
func (m *Model) seal(s sharing, from *picker) tea.Cmd {
read, rules := m.keyReader(), m.shareRules read, rules := m.keyReader(), m.shareRules
m.flash = flash{text: "encrypting with " + s.key.Name + "…"} m.flash = flash{text: "encrypting with " + s.key.Name + "…"}
return func() tea.Msg { return func() tea.Msg {
v, version, err := read(s.key) v, version, err := read(s.key)
if err != nil { if err != nil {
return sealedMsg{err: &share.KeyError{Name: s.key.Name, Err: err}} 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) 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), err: err} return sealedMsg{link: link, n: len(s.files), from: from, err: err}
} }
} }
func (m *Model) handleSealed(msg sealedMsg) tea.Cmd { func (m *Model) handleSealed(msg sealedMsg) tea.Cmd {
picking := m.search != nil && m.search.pick != nil if p := msg.from; p != nil {
if picking { if m.search == nil || m.search.picker != p {
m.search.pickBusy = "" 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
} }
var bad *share.KeyError if msg.err != nil {
switch {
case msg.err != nil && picking && errors.As(msg.err, &bad):
// Picked: say why in the picker, to pick another.
m.search.pickErr = "✗ " + capitalize(bad.Error())
return nil
case msg.err != nil:
m.noteErr(msg.err) m.noteErr(msg.err)
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
return nil return nil
} }
if picking {
m.search = nil
}
m.dismissedLink = msg.link.String() // not offered back on focus m.dismissedLink = msg.link.String() // not offered back on focus
return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false) return m.copyText("encrypted link to "+plural(msg.n, "file"), msg.link.String(), false)
} }
@@ -258,7 +263,7 @@ func (m *Model) openShare(s share.Sealed) tea.Cmd {
msg := shareOpenedMsg{sealed: s} msg := shareOpenedMsg{sealed: s}
v, _, err := read(s.Key) v, _, err := read(s.Key)
if err != nil { if err != nil {
msg.err = fmt.Errorf("reading the key %s: %w", s.Key.Name, err) msg.err = &share.KeyError{Name: s.Key.Name, Err: err}
return msg return msg
} }
if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil { if msg.files, msg.err = s.Open([]byte(v.Reveal())); msg.err != nil {
@@ -287,7 +292,12 @@ func (m *Model) handleShareOpened(msg shareOpenedMsg) tea.Cmd {
case msg.dir == "": case msg.dir == "":
return m.askShareDir(msg) return m.askShareDir(msg)
default: default:
m.offerWrite(msg.files, msg.dir, false) 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 return nil
} }
@@ -310,49 +320,44 @@ func (m *Model) askShareDir(msg shareOpenedMsg) tea.Cmd {
} }
p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"} p := &modal{title: "Save the files", input: &in, lines: lines, submitLabel: "save"}
p.submit = func(m *Model, text string) tea.Cmd { p.submit = func(m *Model, text string) tea.Cmd {
if err := m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)), true); err != nil { dir := expandHome(strings.TrimSpace(text))
p.err = err.Error() targets, err := planSave(msg.files, dir)
m.prompt = p // again, to fix the folder switch {
return p.input.Focus() 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
}
} }
return nil p.err = err.Error()
m.prompt = p // again, to fix the folder
return p.input.Focus()
} }
m.prompt = p m.prompt = p
return p.input.Focus() return p.input.Focus()
} }
// offerWrite shows where each file goes, then writes them as chosen: // planSave says where each file goes in dir, which must be a folder.
// files there already are replaced only when asked. With chosen (the func planSave(files []share.File, dir string) ([]share.Target, error) {
// user just picked dir), new files are written at once, and what goes
// wrong is returned rather than shown.
func (m *Model) offerWrite(files []share.File, dir string, chosen bool) error {
fail := func(err error) error {
if !chosen {
m.flash = flash{text: "save: " + err.Error(), isErr: true}
}
return err
}
if !isDir(dir) { if !isDir(dir) {
return fail(fmt.Errorf("no folder %s", dir)) return nil, fmt.Errorf("no folder %s", dir)
}
targets, err := share.Plan(dir, files)
if err != nil {
return fail(err)
}
exist := 0
for _, t := range targets {
if t.Exists {
exist++
}
}
if chosen && exist == 0 {
return m.writeShared(targets, dir, false) // nothing to ask
} }
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 var lines []string
exist := 0
for _, t := range targets { for _, t := range targets {
file := " " + m.repoLabel(filepath.Base(dir), t.Path) file := " " + m.repoLabel(filepath.Base(dir), t.Path)
line := file + m.st.Muted.Render(" · new") line := file + m.st.Muted.Render(" · new")
if t.Exists { if t.Exists {
exist++
line = file + m.st.Muted.Render(" · replaces the one there") line = file + m.st.Muted.Render(" · replaces the one there")
} }
if t.Exposed { if t.Exposed {
@@ -382,7 +387,6 @@ func (m *Model) offerWrite(files []share.File, dir string, chosen bool) error {
) )
} }
m.showMenu("Save the shared files", lines, opts) m.showMenu("Save the shared files", lines, opts)
return nil
} }
// writeShared writes the files (with onlyNew, those not there yet) and // writeShared writes the files (with onlyNew, those not there yet) and
+67 -3
View File
@@ -15,6 +15,7 @@ import (
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo" "git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/share" "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: // scryptWait is how long the driver waits for encrypting and decrypting:
@@ -50,7 +51,7 @@ func shareFrom(t *testing.T, root string, paste bool) string {
// No share.key set: it asks which secret, in Go to, which lists // No share.key set: it asks which secret, in Go to, which lists
// only secrets. // only secrets.
v := d.m.search v := d.m.search
if v == nil || v.pick == nil { if v == nil || v.picker == nil {
t.Fatalf("no secret picker: %s", d.m.flash.text) t.Fatalf("no secret picker: %s", d.m.flash.text)
} }
for _, o := range v.shown { for _, o := range v.shown {
@@ -210,12 +211,12 @@ func TestShareInDemo(t *testing.T) {
} }
src.Provider = "Repos" src.Provider = "Repos"
d.run(d.m.shareFiles(src, nil)) d.run(d.m.shareFiles(src, nil))
if d.m.search == nil || d.m.search.pick == nil { if d.m.search == nil || d.m.search.picker == nil {
t.Fatalf("no picker: %s", d.m.flash.text) t.Fatalf("no picker: %s", d.m.flash.text)
} }
// Too short a key: the picker stays, saying why. // Too short a key: the picker stays, saying why.
d.send(tea.PasteMsg{Content: "https://kv-app-prod.vault.azure.net/secrets/db-password"}) 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.pickErr, "Can't use db-password: its value has 28 characters; share.keyRules needs 32 (minLength)") { 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()) t.Fatalf("not said in the picker: %v\n%s", d.m.search != nil, d.screen())
} }
if !strings.Contains(d.screen(), "28 characters") { if !strings.Contains(d.screen(), "28 characters") {
@@ -248,3 +249,66 @@ func TestShareInDemo(t *testing.T) {
t.Fatalf("nothing saved (%s)", d.m.flash.text) 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))
}