diff --git a/internal/config/config.go b/internal/config/config.go index a1eb082..d44885c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,7 @@ import ( "go.yaml.in/yaml/v3" "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" ) @@ -242,8 +242,8 @@ func (c Config) validate(path string) error { } } if k := c.Share.Key; k != "" { - if r, err := ref.Parse(k); err != nil || r.IsShare() || r.IsSource() || r.IsContainer() { - return fmt.Errorf("%s: share.key: %q is not a secret's lazysecrets:// link, Key Vault URL or reference", path, k) + if _, err := share.ParseKey(k); err != nil { + return fmt.Errorf("%s: share.key: %w", path, err) } } if n := c.Share.KeyRules.MinLength; n != 0 && n < 16 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d2e86c7..e5d2e94 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -73,6 +73,7 @@ func TestLoadRejectsMistakes(t *testing.T) { "releases": "releases: [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", + "workspace": "share:\n key: \"{{secrets/team/env-share}}\"\n", "short keys": "share:\n keyRules:\n minLength: 8\n", "classes": "share:\n keyRules:\n minClasses: 5\n", } { diff --git a/internal/share/rules.go b/internal/share/rules.go index 38918db..48311c7 100644 --- a/internal/share/rules.go +++ b/internal/share/rules.go @@ -67,13 +67,3 @@ func (r Rules) withDefaults() Rules { r.MinLength = max(r.MinLength, MinKey) 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 } diff --git a/internal/share/share.go b/internal/share/share.go index 9dac614..172235e 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -47,8 +47,8 @@ const ( MaxLink = 16 << 10 // MinKey is the shortest key the rules can ask for. MinKey = 16 - // maxFiles caps what opening a link unpacks. - maxFiles = 1 << 20 + // maxUnpacked caps the bytes opening a link unpacks. + maxUnpacked = 1 << 20 ) // 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 } +// 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. func Read(r ref.Ref) (Sealed, error) { 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) } - 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 { 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") } var plain []file diff --git a/internal/ui/app/jump.go b/internal/ui/app/jump.go index 86a7b94..495ecd9 100644 --- a/internal/ui/app/jump.go +++ b/internal/ui/app/jump.go @@ -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,24 @@ 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 { diff --git a/internal/ui/app/links.go b/internal/ui/app/links.go index e969c0f..9899b95 100644 --- a/internal/ui/app/links.go +++ b/internal/ui/app/links.go @@ -818,7 +818,7 @@ 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.pick != nil { + 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 { diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index 29b9fca..4a467ad 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -53,16 +53,8 @@ type searchView struct { res int // cursor in shown 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 - // secrets only, and enter gives the selected one's link to pick - // 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 + // 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 @@ -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 }) } q := v.input.Value() @@ -438,13 +430,13 @@ 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.pick != nil: + case v.results && v.res < len(v.shown) && v.picker != nil: 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): m.search = nil 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 case !v.results && v.onSource(): m.search = nil @@ -662,15 +654,14 @@ func (m Model) searchViewBox() string { focused := !m.termBlurred w := searchWidth(m.w) popupW := w - if v.pick != nil { + if v.picker != nil { w -= 4 // inside the picker's border, in the popup } listH := max(5, min(m.h-10, 14)) - if v.pick != nil { - // What it's for, the help, the tabs and a border more. + 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.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 @@ -765,16 +756,16 @@ func (m Model) searchViewBox() string { ts := panes.TitleStyle(m.st, focused) var body []string - if v.pick != nil { - for _, l := range v.pickHelp { + if p := v.picker; p != nil { + for _, l := range p.help { body = append(body, " "+l) } switch { - case v.pickBusy != "": - body = append(body, "", " "+m.st.Notice.Render(v.pickBusy)) - case v.pickErr != "": + 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(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)) } } @@ -810,25 +801,46 @@ func (m Model) searchViewBox() string { } // Tabs in the title, the current one underlined, like the panes; a // picker has its own title, and the tabs over the input. - head := ts.Render(goToTitle+" ") + m.goToTabs(ts) - if v.pick == nil { + 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.pickLines { + 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.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) } - 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. func (v *searchView) selectLabel() string { - if v.pick != nil { + if v.picker != nil { return "pick" } return "go" diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 4e44838..d72a7a8 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -7,6 +7,7 @@ import ( "os" "path" "path/filepath" + "slices" "strings" "charm.land/bubbles/v2/textinput" @@ -36,6 +37,8 @@ type shareFilesMsg struct { 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 } @@ -127,32 +130,34 @@ func (m *Model) sharedFiles(s sharing) []string { 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) }}, + {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. +// 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) - m.search.pick = func(m *Model, key ref.Ref) tea.Cmd { - // The picker showed what's shared: no need to ask again. It - // stays open until the link is made, to say why a key can't be - // used. + 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 - m.search.pickErr, m.search.pickBusy = "", "encrypting with "+key.Name+"…" - cmd := m.seal(s) + p.err, p.busy = "", "encrypting with "+key.Name+"…" + cmd := m.seal(s, p) m.flash = flash{} return cmd } - m.search.pickTitle = shareTitle(s) - 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.picker = p m.search.input.SetWidth(searchWidth(m.w) - 10) // inside the picker's border m.fillSearch(false) 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 { key, err := share.ParseKey(strings.TrimSpace(text)) if err != nil { - m.flash = flash{text: "share: " + err.Error(), isErr: true} + m.search.picker.err = "✗ " + capitalize(err.Error()) 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. -func (m *Model) seal(s sharing) tea.Cmd { +// 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{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) - 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 { - picking := m.search != nil && m.search.pick != nil - if picking { - m.search.pickBusy = "" + 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 } - var bad *share.KeyError - 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: + if msg.err != nil { m.noteErr(msg.err) m.flash = flash{text: "share: " + msg.err.Error(), isErr: true} return nil } - if picking { - m.search = 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) } @@ -258,7 +263,7 @@ func (m *Model) openShare(s share.Sealed) tea.Cmd { msg := shareOpenedMsg{sealed: s} v, _, err := read(s.Key) 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 } 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 == "": return m.askShareDir(msg) 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 } @@ -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.submit = func(m *Model, text string) tea.Cmd { - if err := m.offerWrite(msg.files, expandHome(strings.TrimSpace(text)), true); err != nil { - p.err = err.Error() - m.prompt = p // again, to fix the folder - return p.input.Focus() + 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 + } } - 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() } -// offerWrite shows where each file goes, then writes them as chosen: -// files there already are replaced only when asked. With chosen (the -// 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 - } +// 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 fail(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 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 { @@ -382,7 +387,6 @@ func (m *Model) offerWrite(files []share.File, dir string, chosen bool) error { ) } m.showMenu("Save the shared files", lines, opts) - return nil } // writeShared writes the files (with onlyNew, those not there yet) and diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 7aa3699..6e5e214 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -15,6 +15,7 @@ import ( "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: @@ -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 // only secrets. 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) } for _, o := range v.shown { @@ -210,12 +211,12 @@ func TestShareInDemo(t *testing.T) { } src.Provider = "Repos" 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) } // 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.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()) } 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) } } + +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)) +}