diff --git a/internal/share/rules.go b/internal/share/rules.go index 90abe80..6025437 100644 --- a/internal/share/rules.go +++ b/internal/share/rules.go @@ -2,6 +2,7 @@ package share import ( "fmt" + "strings" "unicode" ) @@ -40,11 +41,20 @@ func (r Rules) Check(key []byte) error { } } n := len([]rune(s)) - if n >= r.MinLength && kinds >= r.MinClasses { + var has, needs []string + if n < r.MinLength { + has = append(has, fmt.Sprintf("%d characters", n)) + needs = append(needs, fmt.Sprintf("%d (minLength)", r.MinLength)) + } + if kinds < r.MinClasses { + has = append(has, fmt.Sprintf("%d %s of character", kinds, map[bool]string{true: "kind", false: "kinds"}[kinds == 1])) + needs = append(needs, fmt.Sprintf("%d of lowercase, uppercase, digits and symbols (minClasses)", r.MinClasses)) + } + if len(has) == 0 { return nil } - return fmt.Errorf("the key's value is %d characters of %d kinds, and share.keyRules wants at least %d characters of %d kinds (lowercase, uppercase, digits, symbols): use a random one, like openssl rand -base64 32 makes", - n, kinds, r.MinLength, r.MinClasses) + return fmt.Errorf("its value has %s; share.keyRules needs %s. Use a random one, like openssl rand -base64 32 makes", + strings.Join(has, " and "), strings.Join(needs, " and ")) } func (r Rules) withDefaults() Rules { @@ -57,3 +67,13 @@ 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 2ce72f1..e9a4d22 100644 --- a/internal/share/share.go +++ b/internal/share/share.go @@ -91,13 +91,13 @@ type file struct { } // Seal encrypts files with key, the value of h.Key, into a share link, -// if key keeps to rules. +// if key keeps to rules (*KeyError if not). func Seal(h Header, key []byte, files []File, rules Rules) (ref.Ref, error) { if _, err := ParseKey(h.Key.String()); err != nil { return ref.Ref{}, fmt.Errorf("the key: %w", err) } if err := rules.Check(key); err != nil { - return ref.Ref{}, err + return ref.Ref{}, &KeyError{Name: h.Key.Name, Err: err} } if len(files) == 0 { return ref.Ref{}, errors.New("no files to share") diff --git a/internal/share/share_test.go b/internal/share/share_test.go index 2fc7dc7..d61f803 100644 --- a/internal/share/share_test.go +++ b/internal/share/share_test.go @@ -62,7 +62,7 @@ func TestSealRefuses(t *testing.T) { files []File want string }{ - "short key": {Header{Key: key}, []byte("short"), ok, "keyRules wants"}, + "short key": {Header{Key: key}, []byte("short"), ok, "needs 32 (minLength)"}, "vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "isn't a secret"}, "no files": {Header{Key: key}, pass, nil, "no files"}, "not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"}, diff --git a/internal/ui/app/searchview.go b/internal/ui/app/searchview.go index d9d6bd6..29b9fca 100644 --- a/internal/ui/app/searchview.go +++ b/internal/ui/app/searchview.go @@ -61,6 +61,8 @@ type searchView struct { 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: // typing loads the scope once, so lists that fail aren't asked for @@ -438,8 +440,7 @@ func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd { switch { case v.results && v.res < len(v.shown) && v.pick != nil: r, _ := pickable(v.shown[v.res]) - m.search = nil - return v.pick(m, r) + return v.pick(m, r) // the picker closes when it's done case v.results && v.res < len(v.shown): m.search = nil return v.shown[v.res].run(m) @@ -667,7 +668,9 @@ func (m Model) searchViewBox() string { listH := max(5, min(m.h-10, 14)) if v.pick != nil { // What it's for, the help, the tabs and a border more. - listH = max(5, min(m.h-10-len(v.pickLines)-len(v.pickHelp)-5, 14)) + // 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)) } srcW := 10 @@ -766,6 +769,15 @@ func (m Model) searchViewBox() string { for _, l := range v.pickHelp { body = append(body, " "+l) } + switch { + case v.pickBusy != "": + body = append(body, "", " "+m.st.Notice.Render(v.pickBusy)) + case v.pickErr != "": + body = append(body, "") + for _, l := range strings.Split(ansi.Wordwrap(v.pickErr, w-4, " "), "\n") { + body = append(body, " "+m.st.Error.Render(l)) + } + } body = append(body, "", " "+m.goToTabs(ts)) } body = append(body, " "+v.input.View(), "") diff --git a/internal/ui/app/sharefiles.go b/internal/ui/app/sharefiles.go index 62fbdf2..2030599 100644 --- a/internal/ui/app/sharefiles.go +++ b/internal/ui/app/sharefiles.go @@ -137,8 +137,14 @@ func (m *Model) confirmShare(s sharing) { 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. s.key = key - return m.seal(s) // the picker showed what's shared + m.search.pickErr, m.search.pickBusy = "", "encrypting with "+key.Name+"…" + cmd := m.seal(s) + m.flash = flash{} + return cmd } m.search.pickTitle = shareTitle(s) m.search.pickLines = m.sharedFiles(s) @@ -159,9 +165,7 @@ func (m *Model) pickPasted(text string) tea.Cmd { m.flash = flash{text: "share: " + err.Error(), isErr: true} return nil } - pick := m.search.pick - m.search = nil - return pick(m, key) + return m.search.pick(m, key) } // seal reads the key and encrypts the files with it. @@ -171,7 +175,7 @@ func (m *Model) seal(s sharing) tea.Cmd { return func() tea.Msg { v, version, err := read(s.key) if err != nil { - return sealedMsg{err: fmt.Errorf("reading the key %s: %w", s.key.Name, err)} + return sealedMsg{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} @@ -179,11 +183,24 @@ func (m *Model) seal(s sharing) tea.Cmd { } func (m *Model) handleSealed(msg sealedMsg) tea.Cmd { - if msg.err != nil { + picking := m.search != nil && m.search.pick != nil + if picking { + m.search.pickBusy = "" + } + 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 = "✗ " + strings.ToUpper(bad.Error()[:1]) + bad.Error()[1:] + return nil + case 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) } diff --git a/internal/ui/app/sharefiles_test.go b/internal/ui/app/sharefiles_test.go index 9185d81..5917cee 100644 --- a/internal/ui/app/sharefiles_test.go +++ b/internal/ui/app/sharefiles_test.go @@ -200,8 +200,16 @@ func TestShareInDemo(t *testing.T) { if d.m.search == nil || d.m.search.pick == 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)") { + 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/") { + 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