feat: pick the share key in Go to, or by reference
Sharing without share.key opens Go to as a picker of secrets instead of asking for a link: it lists only secrets, enter picks one, and a pasted reference picks it too. The key, there and in share.key or --key, can be a secret's lazysecrets:// link, Key Vault URL or @Microsoft.KeyVault(...) reference. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
13 files changed
+152
-67
No files matched your search
@@ -119,7 +119,7 @@ func run() error {
|
|||||||
{{secrets/scope/key}}
|
{{secrets/scope/key}}
|
||||||
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
lazysecrets [flags] get <ref> print the value a lazysecrets:// link points at,
|
||||||
for scripts: VAR=$(lazysecrets get <ref>)
|
for scripts: VAR=$(lazysecrets get <ref>)
|
||||||
lazysecrets [flags] share [--key <ref>] <env file>...
|
lazysecrets [flags] share [--key <secret>] <env file>...
|
||||||
print a link to env files of a repo, encrypted
|
print a link to env files of a repo, encrypted
|
||||||
with a secret's value (default: share.key)
|
with a secret's value (default: share.key)
|
||||||
lazysecrets [flags] keybindings print every action and its keys (with your
|
lazysecrets [flags] keybindings print every action and its keys (with your
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ import (
|
|||||||
// lazysecrets share .env apps/api/.env
|
// lazysecrets share .env apps/api/.env
|
||||||
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, args []string, out io.Writer) error {
|
func shareFiles(ctx context.Context, providers []provider.Provider, st *store.Store, general string, args []string, out io.Writer) error {
|
||||||
fs := flag.NewFlagSet("share", flag.ContinueOnError)
|
fs := flag.NewFlagSet("share", flag.ContinueOnError)
|
||||||
keyLink := fs.String("key", "", "lazysecrets:// link to the secret to encrypt with (default: share.key)")
|
keyLink := fs.String("key", "", "the secret to encrypt with: its lazysecrets:// link, Key Vault URL or reference (default: share.key)")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if fs.NArg() == 0 {
|
if fs.NArg() == 0 {
|
||||||
return errors.New("usage: lazysecrets share [--key <link>] <env files>")
|
return errors.New("usage: lazysecrets share [--key <secret>] <env files>")
|
||||||
}
|
}
|
||||||
|
|
||||||
var root, id string
|
var root, id string
|
||||||
@@ -72,7 +72,7 @@ func shareFiles(ctx context.Context, providers []provider.Provider, st *store.St
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("which secret to encrypt with? --key <link>, or share.key in the repo's .lazysecrets.yaml or the config")
|
return errors.New("which secret to encrypt with? --key <secret>, or share.key in the repo's .lazysecrets.yaml or the config")
|
||||||
}
|
}
|
||||||
v, version, err := readKey(ctx, providers, st, key)
|
v, version, err := readKey(ctx, providers, st, key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ providers:
|
|||||||
include: ["secrets/**"]
|
include: ["secrets/**"]
|
||||||
exclude: ["**/testdata/**"]
|
exclude: ["**/testdata/**"]
|
||||||
share: # sharing env files as encrypted links
|
share: # sharing env files as encrypted links
|
||||||
key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share # for repos naming none
|
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link; for repos naming none
|
||||||
```
|
```
|
||||||
|
|
||||||
More, each on its own page: [repos](repos.md) (`providers.repos`),
|
More, each on its own page: [repos](repos.md) (`providers.repos`),
|
||||||
|
|||||||
+9
-4
@@ -49,15 +49,20 @@ az keyvault secret set --vault-name kv-team --name env-share --value "$(openssl
|
|||||||
```
|
```
|
||||||
|
|
||||||
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
|
and name it in the repo's `.lazysecrets.yaml` (committed: the whole team
|
||||||
uses it) or your config (for repos that don't name one), or pick one when
|
uses it) or your config (for repos that don't name one), by its
|
||||||
sharing:
|
`lazysecrets://` link, Key Vault URL or `@Microsoft.KeyVault(...)`
|
||||||
|
reference:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
share:
|
share:
|
||||||
key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share
|
key: https://kv-team.vault.azure.net/secrets/env-share
|
||||||
```
|
```
|
||||||
|
|
||||||
On the command line, `lazysecrets share [--key <link>] <env file>...`
|
With neither, sharing asks which secret, in [Go to](keys.md#go-to): it
|
||||||
|
lists only secrets, and `enter` picks one (pasting a reference picks it
|
||||||
|
too).
|
||||||
|
|
||||||
|
On the command line, `lazysecrets share [--key <secret>] <env file>...`
|
||||||
prints the link.
|
prints the link.
|
||||||
|
|
||||||
Opening a share link says which repo it's from, how many files and which
|
Opening a share link says which repo it's from, how many files and which
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ A repo's `.lazysecrets.yaml` can also name the secret its env files are
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
share:
|
share:
|
||||||
key: lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share
|
key: https://kv-team.vault.azure.net/secrets/env-share # or its lazysecrets:// link
|
||||||
```
|
```
|
||||||
|
|
||||||
## Decrypting
|
## Decrypting
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ type LinkHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Share struct {
|
type Share struct {
|
||||||
// Key is the link to the secret whose value encrypts shared files,
|
// Key is the secret whose value encrypts shared files (its
|
||||||
// for repos whose .lazysecrets.yaml doesn't name one.
|
// lazysecrets:// link, Key Vault URL or reference), for repos whose
|
||||||
|
// .lazysecrets.yaml doesn't name one.
|
||||||
Key string `yaml:"key"`
|
Key string `yaml:"key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +233,7 @@ 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 r, err := ref.Parse(k); err != nil || r.IsShare() || r.IsSource() || r.IsContainer() {
|
||||||
return fmt.Errorf("%s: share.key: %q is not a lazysecrets:// link to a secret", path, k)
|
return fmt.Errorf("%s: share.key: %q is not a secret's lazysecrets:// link, Key Vault URL or reference", path, k)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i, v := range c.Providers.Azure.Vaults {
|
for i, v := range c.Providers.Azure.Vaults {
|
||||||
|
|||||||
+11
-7
@@ -87,8 +87,8 @@ type file struct {
|
|||||||
|
|
||||||
// Seal encrypts files with key, the value of h.Key, into a share link.
|
// Seal encrypts files with key, the value of h.Key, into a share link.
|
||||||
func Seal(h Header, key []byte, files []File) (ref.Ref, error) {
|
func Seal(h Header, key []byte, files []File) (ref.Ref, error) {
|
||||||
if h.Key.IsShare() || h.Key.IsSource() || h.Key.IsContainer() {
|
if _, err := ParseKey(h.Key.String()); err != nil {
|
||||||
return ref.Ref{}, errors.New("the key must be a secret's link")
|
return ref.Ref{}, fmt.Errorf("the key: %w", err)
|
||||||
}
|
}
|
||||||
if len(key) < MinKey {
|
if len(key) < MinKey {
|
||||||
return ref.Ref{}, fmt.Errorf("the key's value is %d characters: use a random one of at least %d", len(key), MinKey)
|
return ref.Ref{}, fmt.Errorf("the key's value is %d characters: use a random one of at least %d", len(key), MinKey)
|
||||||
@@ -248,14 +248,18 @@ func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
|
|||||||
return key, true, nil
|
return key, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseKey reads a key's link: a link to a secret.
|
// ParseKey reads which secret is the key: its lazysecrets:// link, Key
|
||||||
func ParseKey(link string) (ref.Ref, error) {
|
// Vault URL or @Microsoft.KeyVault(...) reference.
|
||||||
key, err := ref.Parse(link)
|
func ParseKey(s string) (ref.Ref, error) {
|
||||||
|
key, err := ref.Parse(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ref.Ref{}, err
|
return ref.Ref{}, err
|
||||||
}
|
}
|
||||||
if key.IsShare() || key.IsSource() || key.IsContainer() {
|
switch {
|
||||||
return ref.Ref{}, fmt.Errorf("%s isn't a secret's link", link)
|
case key.IsShare() || key.IsSource() || key.IsContainer() || (key.Type != "" && key.Type != provider.TypeSecret):
|
||||||
|
return ref.Ref{}, fmt.Errorf("%s isn't a secret", s)
|
||||||
|
case key.Kind == provider.KindDatabricks && key.Host == "":
|
||||||
|
return ref.Ref{}, fmt.Errorf("%s names no workspace: use its lazysecrets:// link", s)
|
||||||
}
|
}
|
||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func TestSealRefuses(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
"short key": {Header{Key: key}, []byte("short"), ok, "at least 16"},
|
"short key": {Header{Key: key}, []byte("short"), ok, "at least 16"},
|
||||||
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "secret's link"},
|
"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"},
|
"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"},
|
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
|
||||||
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
|
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
|
||||||
@@ -168,6 +168,29 @@ func TestPlanSymlinks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseKey(t *testing.T) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share",
|
||||||
|
"https://kv-team.vault.azure.net/secrets/env-share",
|
||||||
|
"@Microsoft.KeyVault(VaultName=kv-team;SecretName=env-share)",
|
||||||
|
"lazysecrets://databricks/adb-1.1.azuredatabricks.net/team/env-share",
|
||||||
|
} {
|
||||||
|
if k, err := ParseKey(s); err != nil || k.Name != "env-share" {
|
||||||
|
t.Errorf("%s: %+v, %v", s, k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for s, want := range map[string]string{
|
||||||
|
"{{secrets/team/env-share}}": "names no workspace",
|
||||||
|
"https://kv-team.vault.azure.net/keys/sops": "isn't a secret",
|
||||||
|
"https://kv-team.vault.azure.net/": "isn't a secret",
|
||||||
|
"lazysecrets://azure/subscriptions/0000-1111": "isn't a secret",
|
||||||
|
} {
|
||||||
|
if _, err := ParseKey(s); err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("%s: %v, want %q", s, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultKey(t *testing.T) {
|
func TestDefaultKey(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
|
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
|
||||||
|
|||||||
@@ -273,9 +273,7 @@ type Model struct {
|
|||||||
linked []provider.Container // vaults opened from links, this session
|
linked []provider.Container // vaults opened from links, this session
|
||||||
open *ref.Ref // reference to open at startup
|
open *ref.Ref // reference to open at startup
|
||||||
|
|
||||||
// shareKey is the config's share.key; lastShareKey the key last
|
shareKey string // the config's share.key
|
||||||
// typed when sharing, offered next time.
|
|
||||||
shareKey, lastShareKey string
|
|
||||||
|
|
||||||
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
||||||
dismissedLink string // the last link offered: not offered again
|
dismissedLink string // the last link offered: not offered again
|
||||||
|
|||||||
@@ -818,6 +818,9 @@ 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 {
|
||||||
|
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 {
|
||||||
in, cmd := p.input.Update(msg)
|
in, cmd := p.input.Update(msg)
|
||||||
p.input = &in
|
p.input = &in
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
"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/store"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
@@ -52,6 +53,12 @@ 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
|
||||||
|
// 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, pickNote string
|
||||||
|
|
||||||
// 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
|
||||||
// again on every key.
|
// again on every key.
|
||||||
@@ -267,6 +274,9 @@ func (v *searchView) match() []option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if v.pick != nil {
|
||||||
|
cand = slices.DeleteFunc(slices.Clone(cand), func(o option) bool { _, ok := pickable(o); return !ok })
|
||||||
|
}
|
||||||
q := v.input.Value()
|
q := v.input.Value()
|
||||||
if q == "" {
|
if q == "" {
|
||||||
return slices.Clone(cand)
|
return slices.Clone(cand)
|
||||||
@@ -423,9 +433,15 @@ 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:
|
||||||
|
r, _ := pickable(v.shown[v.res])
|
||||||
|
m.search = nil
|
||||||
|
return v.pick(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:
|
||||||
|
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
|
||||||
return v.scope().run(m)
|
return v.scope().run(m)
|
||||||
@@ -745,6 +761,9 @@ func (m Model) searchViewBox() string {
|
|||||||
} else {
|
} else {
|
||||||
body = append(body, "") // the same height on every tab
|
body = append(body, "") // the same height on every tab
|
||||||
}
|
}
|
||||||
|
if v.pick != nil && v.pickNote != "" {
|
||||||
|
body = append(body, " "+m.st.Muted.Render(v.pickNote))
|
||||||
|
}
|
||||||
km := m.keys
|
km := m.keys
|
||||||
var foot string
|
var foot string
|
||||||
switch {
|
switch {
|
||||||
@@ -753,17 +772,20 @@ func (m Model) searchViewBox() string {
|
|||||||
if v.tab == tabSearch {
|
if v.tab == tabSearch {
|
||||||
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
|
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:
|
default:
|
||||||
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
|
||||||
if v.tab == tabSearch {
|
if v.tab == tabSearch {
|
||||||
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
|
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.
|
// Tabs in the title, the current one underlined, like the panes.
|
||||||
ts := panes.TitleStyle(m.st, focused)
|
ts := panes.TitleStyle(m.st, focused)
|
||||||
head := ts.Render(goToTitle + " ")
|
head := ts.Render(goToTitle + " ")
|
||||||
|
if v.pick != nil {
|
||||||
|
head = ts.Render(v.pickTitle + " ")
|
||||||
|
}
|
||||||
for i, name := range tabNames {
|
for i, name := range tabNames {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
head += ts.Render(" - ")
|
head += ts.Render(" - ")
|
||||||
@@ -776,3 +798,21 @@ func (m Model) searchViewBox() string {
|
|||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// selectLabel names what enter does with the selected entry.
|
||||||
|
func (v *searchView) selectLabel() string {
|
||||||
|
if v.pick != 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).
|
||||||
|
func pickable(o option) (ref.Ref, bool) {
|
||||||
|
r, err := ref.Parse(o.star)
|
||||||
|
if err != nil || r.IsShare() || r.IsSource() || r.IsContainer() || (r.Type != "" && r.Type != provider.TypeSecret) {
|
||||||
|
return ref.Ref{}, false
|
||||||
|
}
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ func (m *Model) handleShareFiles(msg shareFilesMsg) tea.Cmd {
|
|||||||
case msg.err != nil:
|
case msg.err != nil:
|
||||||
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
m.flash = flash{text: "share: " + msg.err.Error(), isErr: true}
|
||||||
case msg.key == (ref.Ref{}):
|
case msg.key == (ref.Ref{}):
|
||||||
return m.askShareKey(msg.sharing)
|
return m.pickShareKey(msg.sharing)
|
||||||
default:
|
default:
|
||||||
m.confirmShare(msg.sharing)
|
m.confirmShare(msg.sharing)
|
||||||
}
|
}
|
||||||
@@ -118,40 +118,34 @@ func (m *Model) confirmShare(s sharing) {
|
|||||||
lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
|
lines = append(lines, "", m.st.Muted.Render("encrypted with ")+m.refLabel(s.key))
|
||||||
m.showMenu("Share as an encrypted link", lines, []option{
|
m.showMenu("Share as an encrypted link", 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) }},
|
||||||
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.askShareKey(s) }},
|
{key: "k", label: "Encrypt with another secret…", run: func(m *Model) tea.Cmd { return m.pickShareKey(s) }},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// askShareKey asks for the link to the secret to encrypt with.
|
// pickShareKey asks which secret to encrypt with, in Go to.
|
||||||
func (m *Model) askShareKey(s sharing) tea.Cmd {
|
func (m *Model) pickShareKey(s sharing) tea.Cmd {
|
||||||
in := textinput.New()
|
cmd := m.openGoTo(tabSearch, true)
|
||||||
in.Prompt = "key: "
|
m.search.pick = func(m *Model, key ref.Ref) tea.Cmd {
|
||||||
in.Placeholder = "lazysecrets:// link to a secret"
|
s.key = key
|
||||||
in.SetWidth(max(20, min(m.w-12, 64)))
|
m.confirmShare(s)
|
||||||
in.SetValue(m.lastShareKey)
|
return nil
|
||||||
if s.key != (ref.Ref{}) {
|
|
||||||
in.SetValue(s.key.String())
|
|
||||||
}
|
}
|
||||||
in.CursorEnd()
|
m.search.pickTitle = "Encrypt with which secret?"
|
||||||
m.prompt = &modal{
|
m.search.pickNote = "Whoever can read it can open the link. share.key sets a default."
|
||||||
title: "Encrypt with which secret?", input: &in,
|
m.fillSearch(false)
|
||||||
lines: []string{
|
return tea.Batch(cmd, m.searchLoads())
|
||||||
m.st.Muted.Render("Whoever can read it can open the link. s on a secret copies its link."),
|
}
|
||||||
m.st.Muted.Render("share.key in the config or the repo's .lazysecrets.yaml sets one."),
|
|
||||||
},
|
// pickPasted picks a pasted reference to a secret, while picking one.
|
||||||
submit: func(m *Model, 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.flash = flash{text: "share: " + err.Error(), isErr: true}
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
m.lastShareKey = key.String()
|
|
||||||
s.key = key
|
|
||||||
m.confirmShare(s)
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
return m.prompt.input.Focus()
|
pick := m.search.pick
|
||||||
|
m.search = nil
|
||||||
|
return pick(m, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// seal reads the key and encrypts the files with it.
|
// seal reads the key and encrypts the files with it.
|
||||||
|
|||||||
@@ -18,9 +18,6 @@ import (
|
|||||||
// which take about a second on purpose.
|
// which take about a second on purpose.
|
||||||
const scryptWait = 20 * time.Second
|
const scryptWait = 20 * time.Second
|
||||||
|
|
||||||
// shareKeyLink is a demo secret, long enough to be a key.
|
|
||||||
const shareKeyLink = "lazysecrets://azure/" + kv + "/secrets/db-password"
|
|
||||||
|
|
||||||
// cloneWith makes a clone of git.example.com/team/app holding files.
|
// cloneWith makes a clone of git.example.com/team/app holding files.
|
||||||
func cloneWith(t *testing.T, files map[string]string) string {
|
func cloneWith(t *testing.T, files map[string]string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -35,7 +32,7 @@ func cloneWith(t *testing.T, files map[string]string) string {
|
|||||||
|
|
||||||
// shareFrom shares a repo's env files from lazysecrets started in it,
|
// shareFrom shares a repo's env files from lazysecrets started in it,
|
||||||
// returning the link copied.
|
// returning the link copied.
|
||||||
func shareFrom(t *testing.T, root string) string {
|
func shareFrom(t *testing.T, root string, paste bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
clip := &fakeClipboard{}
|
clip := &fakeClipboard{}
|
||||||
d := newDriverOpts(t, Options{Clipboard: clip}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
d := newDriverOpts(t, Options{Clipboard: clip}, fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||||
@@ -44,12 +41,32 @@ func shareFrom(t *testing.T, root string) string {
|
|||||||
t.Fatalf("selected %v, want the repo", src.ID)
|
t.Fatalf("selected %v, want the repo", src.ID)
|
||||||
}
|
}
|
||||||
d.keys("S", "f")
|
d.keys("S", "f")
|
||||||
// No share.key set: it asks which secret.
|
// No share.key set: it asks which secret, in Go to, which lists
|
||||||
if d.m.prompt == nil || d.m.prompt.input == nil {
|
// only secrets.
|
||||||
t.Fatalf("no key prompt: %s", d.m.flash.text)
|
v := d.m.search
|
||||||
|
if v == nil || v.pick == nil {
|
||||||
|
t.Fatalf("no secret picker: %s", d.m.flash.text)
|
||||||
|
}
|
||||||
|
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(), "Encrypt with which secret?") {
|
||||||
|
t.Errorf("not titled as a picker:\n%s", d.screen())
|
||||||
|
}
|
||||||
|
d.keys("enter")
|
||||||
}
|
}
|
||||||
d.send(tea.PasteMsg{Content: shareKeyLink})
|
|
||||||
d.keys("enter")
|
|
||||||
if !strings.Contains(d.screen(), ".env") || !strings.Contains(d.screen(), "db-password") {
|
if !strings.Contains(d.screen(), ".env") || !strings.Contains(d.screen(), "db-password") {
|
||||||
t.Errorf("the files and key aren't shown before sharing:\n%s", d.screen())
|
t.Errorf("the files and key aren't shown before sharing:\n%s", d.screen())
|
||||||
}
|
}
|
||||||
@@ -67,7 +84,7 @@ func TestShareFilesRoundTrip(t *testing.T) {
|
|||||||
link := shareFrom(t, cloneWith(t, map[string]string{
|
link := shareFrom(t, cloneWith(t, map[string]string{
|
||||||
".env": "DB_PASSWORD=hunter2\n",
|
".env": "DB_PASSWORD=hunter2\n",
|
||||||
"prod.env": "TOKEN=abc\n",
|
"prod.env": "TOKEN=abc\n",
|
||||||
}))
|
}), false)
|
||||||
|
|
||||||
// A colleague's clone: .env is there already, prod.env isn't.
|
// A colleague's clone: .env is there already, prod.env isn't.
|
||||||
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
|
theirs := cloneWith(t, map[string]string{".env": "DB_PASSWORD=old\n"})
|
||||||
@@ -104,7 +121,7 @@ func TestShareLinkElsewhereAsksWhere(t *testing.T) {
|
|||||||
if _, err := exec.LookPath("git"); err != nil {
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
t.Skip("no git")
|
t.Skip("no git")
|
||||||
}
|
}
|
||||||
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}))
|
link := shareFrom(t, cloneWith(t, map[string]string{".env": "A=1\n"}), true)
|
||||||
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||||
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
|
d := newDriverOpts(t, Options{Dir: dir}, fake.Azure(0))
|
||||||
d.wait = scryptWait
|
d.wait = scryptWait
|
||||||
|
|||||||
Reference in new issue
Block a user