feat: share env files as encrypted links #54
No files matched your search
@@ -7,6 +7,11 @@ go test ./...
|
|||||||
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
|
go test ./internal/ui/app -run TestGolden -update # after intentional UI changes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
In `--demo`, sharing env files works end to end: `S` → `f` on a demo
|
||||||
|
repo, pick `kv-shared-prod / env-share` (the second account's) as the
|
||||||
|
key, then paste the link back. The demo repos aren't on disk, so opening
|
||||||
|
asks where to save, as for a repo you haven't cloned.
|
||||||
|
|
||||||
A stripped binary is about 21 MB instead of 30 MB:
|
A stripped binary is about 21 MB instead of 30 MB:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -331,6 +331,10 @@ func Azure(delay time.Duration) *Provider {
|
|||||||
}, demoCert)
|
}, demoCert)
|
||||||
|
|
||||||
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
shared := p.addContainer(prod, "kv-shared-prod", "https://kv-shared-prod.vault.azure.net", allTypes...)
|
||||||
|
// A key to share env files with (S → f): random, as share.keyRules wants.
|
||||||
|
p.add(shared, provider.TypeSecret, provider.Object{
|
||||||
|
Name: "env-share", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "env-share"),
|
||||||
|
}, "q8Zt3VwL1mXc7Rk2Pj9Ys4Hn6Bf0Ga5Ud+Te/OiWlE=")
|
||||||
p.add(shared, provider.TypeSecret, provider.Object{
|
p.add(shared, provider.TypeSecret, provider.Object{
|
||||||
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
Name: "smtp-password", Enabled: &yes, Updated: base, Reference: secretRef("kv-shared-prod", "smtp-password"),
|
||||||
}, "smtp-demo-password")
|
}, "smtp-demo-password")
|
||||||
|
|||||||
@@ -134,3 +134,18 @@ func format(path string) string {
|
|||||||
}
|
}
|
||||||
return "dotenv"
|
return "dotenv"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ provider.Contents = (*Provider)(nil)
|
||||||
|
|
||||||
|
// Contents is a plain env file as KEY=value lines, from its keys, so the
|
||||||
|
// demo repos' env files can be shared.
|
||||||
|
func (p *Provider) Contents(ctx context.Context, c provider.Container) ([]byte, error) {
|
||||||
|
if c.Detail != provider.DetailPlainEnv {
|
||||||
|
return nil, provider.ErrUnsupported
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for _, o := range p.objects[listKey{c.ID, provider.TypeSecret}] {
|
||||||
|
b.WriteString(o.Name + "=" + p.values[o.ID] + "\n")
|
||||||
|
}
|
||||||
|
return []byte(b.String()), ctx.Err()
|
||||||
|
}
|
||||||
@@ -189,6 +189,12 @@ type Files interface {
|
|||||||
File(c Container, o *Object) (File, error)
|
File(c Container, o *Object) (File, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contents is implemented by providers whose containers are files, so
|
||||||
|
// they can be shared (package share): the file as it is.
|
||||||
|
type Contents interface {
|
||||||
|
Contents(ctx context.Context, c Container) ([]byte, error)
|
||||||
|
}
|
||||||
|
|
||||||
// File is a container's file, to open.
|
// File is a container's file, to open.
|
||||||
type File struct {
|
type File struct {
|
||||||
Path string
|
Path string
|
||||||
|
|||||||
@@ -412,6 +412,15 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
|||||||
return provider.NewSecretValue(stdout.Bytes()), nil
|
return provider.NewSecretValue(stdout.Bytes()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contents reads the file c is.
|
||||||
|
func (p *Provider) Contents(_ context.Context, c provider.Container) ([]byte, error) {
|
||||||
|
f, err := p.File(c, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return os.ReadFile(f.Path)
|
||||||
|
}
|
||||||
|
|
||||||
// File is the file c is, and the line o's key is on.
|
// File is the file c is, and the line o's key is on.
|
||||||
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
|
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
|
||||||
path := c.ID
|
path := c.ID
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ type sealedMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// canShareFiles reports whether src's env files can be shared: it's a
|
// canShareFiles reports whether src's env files can be shared: it's a
|
||||||
// repo whose files are on disk.
|
// repo whose files can be read.
|
||||||
func (m Model) canShareFiles(src provider.Source) bool {
|
func (m Model) canShareFiles(src provider.Source) bool {
|
||||||
_, ok := m.providerFor(src).(provider.Files)
|
_, ok := m.providerFor(src).(provider.Contents)
|
||||||
return src.Kind == provider.KindRepo && ok
|
return src.Kind == provider.KindRepo && ok
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ func (m Model) canShareFiles(src provider.Source) bool {
|
|||||||
// plain ones.
|
// plain ones.
|
||||||
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
|
func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd {
|
||||||
p := m.providerFor(src)
|
p := m.providerFor(src)
|
||||||
files, ok := p.(provider.Files)
|
files, ok := p.(provider.Contents)
|
||||||
if !ok {
|
if !ok {
|
||||||
m.flash = warning("these files can't be shared")
|
m.flash = warning("these files can't be shared")
|
||||||
return nil
|
return nil
|
||||||
@@ -78,16 +78,12 @@ func (m *Model) shareFiles(src provider.Source, cs []provider.Container) tea.Cmd
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, c := range cs {
|
for _, c := range cs {
|
||||||
f, err := files.File(c, nil)
|
b, err := files.Contents(ctx, c)
|
||||||
if err == nil {
|
|
||||||
var b []byte
|
|
||||||
b, err = os.ReadFile(f.Path)
|
|
||||||
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg.err = err
|
msg.err = err
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
msg.files = append(msg.files, share.File{Path: c.Name, Data: b})
|
||||||
}
|
}
|
||||||
key, ok, err := share.DefaultKey(src.ID, general)
|
key, ok, err := share.DefaultKey(src.ID, general)
|
||||||
if ok {
|
if ok {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
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/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"
|
||||||
@@ -175,3 +176,44 @@ func TestShareLinkGarbled(t *testing.T) {
|
|||||||
t.Errorf("not reported:\n%s", d.screen())
|
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.pick == nil {
|
||||||
|
t.Fatalf("no picker: %s", d.m.flash.text)
|
||||||
|
}
|
||||||
|
d.send(tea.PasteMsg{Content: "https://kv-shared-prod.vault.azure.net/secrets/env-share"})
|
||||||
|
if !strings.HasPrefix(clip.content, "lazysecrets://share/") {
|
||||||
|
t.Fatalf("copied %q (%s)", clip.content, d.m.flash.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, _ := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
d.m.placeDir = dir
|
||||||
|
d.send(tea.PasteMsg{Content: clip.content})
|
||||||
|
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", "w")
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if len(entries) == 0 {
|
||||||
|
t.Fatalf("nothing saved (%s)", d.m.flash.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user