feat: formatted JSON and certificates, errors in the status pane, no scope access list (#19)
**Formatted values.** A revealed value is shown as it reads best: JSON indented, and PEM certificates as subject, issuer, valid dates, names and SHA-1 thumbprint instead of base64. Any other PEM block (a private key in a bundle) is named, `EC PRIVATE KEY (not shown)`, never shown. Anything else shows as before. `y` copies the value exactly as stored, and the hints line says so (`JSON, formatted: y copies it as stored`). **Errors in the status pane.** This session's errors, newest first, up to 20, with the time and what they were about (`12:32 kv-restricted: 403 Forbidden: …`): lists that couldn't load, values and details that couldn't be read, sources, saving the cache, and whatever the footer flashed (a failed copy) before you moved on. A repeat moves to the top. Sign-ins aren't listed (the footer and the account say those). Only the first line of each, and not kept after quitting. `y` copies one. **No access list for Databricks scopes.** It was the one request of its own left (one call per scope). Removed with its tests and docs; refusals still say what to check. **Copying over SSH: checked, nothing to change.** Over SSH (`SSH_TTY`/`SSH_CONNECTION`), `y` copies with OSC 52, and the 30-second clear works the same way. Your tmux (3.7, `set-clipboard on`) passes it through and kitty (`clipboard_control write-clipboard`) accepts it; docs/config.md already says what's needed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com> Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
1 parent
17054ad0ec
commit
b530c043f7
14 files changed
+271
-90
No files matched your search
@@ -17,10 +17,6 @@ When Databricks refuses, the pane says what to check: access to the
|
|||||||
workspace and READ on the scope, or, when the workspace's IP access list
|
workspace and READ on the scope, or, when the workspace's IP access list
|
||||||
refused, the network you're on.
|
refused, the network you're on.
|
||||||
|
|
||||||
On a scope, the right pane shows who has access to it and how much
|
|
||||||
(manage, write, read). Databricks shows that only to the scope's
|
|
||||||
managers; to others it says so.
|
|
||||||
|
|
||||||
Hide profiles you don't want in the [config](config.md):
|
Hide profiles you don't want in the [config](config.md):
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
+15
-8
@@ -49,7 +49,7 @@ match case exactly.
|
|||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
| `space` | reveal / hide the selected value (public material for keys and certificates) |
|
||||||
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field or group |
|
| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field |
|
||||||
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
| `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) |
|
||||||
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
| `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back |
|
||||||
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
| `o` | open the selected item in the browser (over SSH: copy the link) |
|
||||||
@@ -60,6 +60,11 @@ match case exactly.
|
|||||||
| `L` | sign in again (shown when a login has expired) |
|
| `L` | sign in again (shown when a login has expired) |
|
||||||
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
|
| `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one |
|
||||||
|
|
||||||
|
A revealed value is shown as it reads best: JSON indented, and PEM
|
||||||
|
certificates as their subject, issuer, dates, names and thumbprint, with
|
||||||
|
any other PEM block (a private key) named but not shown. `y` still copies
|
||||||
|
it exactly as stored.
|
||||||
|
|
||||||
A revealed value hides again after `revealTimeout` (60s) and when the
|
A revealed value hides again after `revealTimeout` (60s) and when the
|
||||||
terminal loses focus (`hideOnBlur`); see [config](config.md).
|
terminal loses focus (`hideOnBlur`); see [config](config.md).
|
||||||
|
|
||||||
@@ -114,13 +119,15 @@ it's hidden.
|
|||||||
|
|
||||||
On top, the status pane (`0`) shows the version, a newer release and how
|
On top, the status pane (`0`) shows the version, a newer release and how
|
||||||
your accounts are doing. Focused, the right pane lists more under the
|
your accounts are doing. Focused, the right pane lists more under the
|
||||||
logo (when there's room): the version
|
logo (when there's room): the version and updates (`U`), each account
|
||||||
and updates (`U`), each account (`L` on one that needs it signs in), the
|
(`L` on one that needs it signs in), what's expiring (`enter` goes to
|
||||||
config, state and cache files (`e` opens one in your editor), and links
|
it), this session's errors, newest first (what a pane showed, or the
|
||||||
to the docs, source, issues, releases and changelog (`o` opens one; on
|
footer flashed before you moved on; not kept after quitting), the config,
|
||||||
the version or update, its release), then the changelog of
|
state and cache files (`e` opens one in your editor), and links to the
|
||||||
every release up to yours, built in so it needs no network: `/` filters
|
docs, source, issues, releases and changelog (`o` opens one; on the
|
||||||
it (`/fixed`, `/search`). `y` copies any row.
|
version or update, its release), then the changelog of every release up
|
||||||
|
to yours, built in so it needs no network: `/` filters it (`/fixed`,
|
||||||
|
`/search`). `y` copies any row.
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -140,15 +140,8 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server {
|
|||||||
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000},
|
||||||
map[string]any{"key": "client-secret"},
|
map[string]any{"key": "client-secret"},
|
||||||
}})
|
}})
|
||||||
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "app":
|
|
||||||
write(w, 200, map[string]any{"items": []any{
|
|
||||||
map[string]any{"principal": "users", "permission": "READ"},
|
|
||||||
map[string]any{"principal": "admins", "permission": "MANAGE"},
|
|
||||||
}})
|
|
||||||
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
|
case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked":
|
||||||
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
|
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"})
|
||||||
case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "locked":
|
|
||||||
write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have MANAGE permission"})
|
|
||||||
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app":
|
||||||
write(w, 200, map[string]any{"key": q.Get("key"),
|
write(w, 200, map[string]any{"key": q.Get("key"),
|
||||||
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
"value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))})
|
||||||
@@ -301,23 +294,6 @@ func TestClassify(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestScopeDetails(t *testing.T) {
|
|
||||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
|
||||||
src := provider.Source{ID: "prod"}
|
|
||||||
attrs, err := p.Details(context.Background(), provider.Container{Source: src, Name: "app"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []provider.Attr{{Label: "access", Value: "admins · manage"}, {Value: "users · read"}}
|
|
||||||
if !reflect.DeepEqual(attrs, want) {
|
|
||||||
t.Errorf("app: %+v", attrs)
|
|
||||||
}
|
|
||||||
attrs, err = p.Details(context.Background(), provider.Container{Source: src, Name: "locked"})
|
|
||||||
if err != nil || len(attrs) != 1 || !strings.Contains(attrs[0].Value, "managers") {
|
|
||||||
t.Errorf("locked: %+v, %v", attrs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeniedSaysWhatToCheck(t *testing.T) {
|
func TestDeniedSaysWhatToCheck(t *testing.T) {
|
||||||
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
p := newTestProvider(t, workspaceAPI(t, 0), nil)
|
||||||
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
|
_, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret)
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package databricks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/databricks/databricks-sdk-go/apierr"
|
|
||||||
"github.com/databricks/databricks-sdk-go/service/workspace"
|
|
||||||
|
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Details are who has access to a scope, and how much. Only the scope's
|
|
||||||
// managers may see that; to others it says so.
|
|
||||||
func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) {
|
|
||||||
w, pr, err := p.client(c.Source.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
acls, err := w.ListAclsAll(ctx, workspace.ListAclsRequest{Scope: c.Name})
|
|
||||||
var api *apierr.APIError
|
|
||||||
switch {
|
|
||||||
case errors.As(err, &api) && api.StatusCode == 403:
|
|
||||||
return []provider.Attr{{Label: "access", Value: "only the scope's managers can see who has it"}}, nil
|
|
||||||
case err != nil:
|
|
||||||
return nil, classify(pr, err)
|
|
||||||
case len(acls) == 0:
|
|
||||||
return []provider.Attr{{Label: "access", Value: "workspace admins only"}}, nil
|
|
||||||
}
|
|
||||||
rank := map[workspace.AclPermission]int{workspace.AclPermissionManage: 0, workspace.AclPermissionWrite: 1, workspace.AclPermissionRead: 2}
|
|
||||||
slices.SortFunc(acls, func(a, b workspace.AclItem) int {
|
|
||||||
return cmp.Or(cmp.Compare(rank[a.Permission], rank[b.Permission]), cmp.Compare(a.Principal, b.Principal))
|
|
||||||
})
|
|
||||||
out := make([]provider.Attr, len(acls))
|
|
||||||
for i, a := range acls {
|
|
||||||
out[i] = provider.Attr{Value: a.Principal + " · " + strings.ToLower(string(a.Permission))}
|
|
||||||
}
|
|
||||||
out[0].Label = "access"
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -221,9 +221,8 @@ func (d *Denied) Error() string {
|
|||||||
func (d *Denied) Unwrap() error { return d.Err }
|
func (d *Denied) Unwrap() error { return d.Err }
|
||||||
|
|
||||||
// ContainerDetails is implemented by providers that can say more about a
|
// ContainerDetails is implemented by providers that can say more about a
|
||||||
// container than its listing does: a vault's settings, who has access to
|
// container than its listing does: how a file is encrypted and what git
|
||||||
// a scope, how a file is encrypted and what git knows of it. It may go
|
// knows of it. ErrUnsupported means there's nothing more for c.
|
||||||
// over the network; ErrUnsupported means there's nothing more for c.
|
|
||||||
type ContainerDetails interface {
|
type ContainerDetails interface {
|
||||||
Details(ctx context.Context, c Container) ([]Attr, error)
|
Details(ctx context.Context, c Container) ([]Attr, error)
|
||||||
}
|
}
|
||||||
+18
-4
@@ -56,7 +56,7 @@ const (
|
|||||||
slotDescribe // extra metadata for the selected object
|
slotDescribe // extra metadata for the selected object
|
||||||
slotCopy // fetch a value straight to the clipboard
|
slotCopy // fetch a value straight to the clipboard
|
||||||
slotVersions // count the selected object's versions
|
slotVersions // count the selected object's versions
|
||||||
slotDetails // a vault's, scope's or file's details
|
slotDetails // a file's details
|
||||||
slotCount
|
slotCount
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,9 +177,13 @@ type Model struct {
|
|||||||
fields panes.Fields
|
fields panes.Fields
|
||||||
lastLeft pane
|
lastLeft pane
|
||||||
details map[string]detailsState // by containerKey
|
details map[string]detailsState // by containerKey
|
||||||
getenv func(string) string
|
// errLog is this session's errors, newest first; loggedFlash the
|
||||||
nvimOpen func(server, path string, line int) error // edit.go
|
// footer's text when last looked at (errlog.go).
|
||||||
now func() time.Time
|
errLog []loggedErr
|
||||||
|
loggedFlash string
|
||||||
|
getenv func(string) string
|
||||||
|
nvimOpen func(server, path string, line int) error // edit.go
|
||||||
|
now func() time.Time
|
||||||
// The cache file, whether the cache changed since it was saved, and
|
// The cache file, whether the cache changed since it was saved, and
|
||||||
// when each provider's Sources list was loaded (persist.go).
|
// when each provider's Sources list was loaded (persist.go).
|
||||||
cachePath string
|
cachePath string
|
||||||
@@ -623,6 +627,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.done(slotContainers)
|
m.done(slotContainers)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
|
if src, ok := m.selectedSource(); ok {
|
||||||
|
m.logErrOf(src.Name, msg.err)
|
||||||
|
}
|
||||||
delete(m.cache.containers, msg.key)
|
delete(m.cache.containers, msg.key)
|
||||||
m.containers.SetError(msg.err)
|
m.containers.SetError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
@@ -641,6 +648,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.closeVersions()
|
m.closeVersions()
|
||||||
} else if msg.err != nil {
|
} else if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
|
if c, ok := m.containers.Selected(); ok {
|
||||||
|
m.logErrOf(c.Name, msg.err)
|
||||||
|
}
|
||||||
delete(m.cache.objects, msg.key)
|
delete(m.cache.objects, msg.key)
|
||||||
m.secrets.SetError(msg.err)
|
m.secrets.SetError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
@@ -663,6 +673,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.done(slotValue)
|
m.done(slotValue)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
|
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||||
m.detail.SetValueError(msg.err)
|
m.detail.SetValueError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
m.detail.SetValue(msg.value)
|
m.detail.SetValue(msg.value)
|
||||||
@@ -689,6 +700,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.done(slotDescribe)
|
m.done(slotDescribe)
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(msg.err)
|
m.noteErr(msg.err)
|
||||||
|
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
||||||
m.detail.SetDescribeError(msg.err)
|
m.detail.SetDescribeError(msg.err)
|
||||||
} else {
|
} else {
|
||||||
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
|
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
|
||||||
@@ -759,6 +771,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
if m.focus != paneDetail {
|
if m.focus != paneDetail {
|
||||||
m.lastLeft = m.focus
|
m.lastLeft = m.focus
|
||||||
}
|
}
|
||||||
|
m.logFlash()
|
||||||
cmds = append(cmds, m.syncFields())
|
cmds = append(cmds, m.syncFields())
|
||||||
if m.anyLoading() && !m.spinning {
|
if m.anyLoading() && !m.spinning {
|
||||||
m.spinning = true
|
m.spinning = true
|
||||||
@@ -1353,6 +1366,7 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
|||||||
for _, p := range m.providers {
|
for _, p := range m.providers {
|
||||||
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
||||||
m.noteErr(r.err)
|
m.noteErr(r.err)
|
||||||
|
m.logErrOf(p.Label(), r.err)
|
||||||
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
|
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1300,3 +1300,25 @@ func TestExpiringInStatus(t *testing.T) {
|
|||||||
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
|
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestErrorLog(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
d := newDriverOpts(t, Options{}, fake.Azure(0))
|
||||||
|
d.keys("2", "j", "j", "3") // kv-restricted: its secrets can't be listed
|
||||||
|
d.m.flash = flash{text: "copy failed: no clipboard", isErr: true}
|
||||||
|
d.send(nil)
|
||||||
|
d.send(nil) // still showing: logged once
|
||||||
|
d.keys("0")
|
||||||
|
scr := d.screen()
|
||||||
|
if !strings.Contains(scr, "errors") || !strings.Contains(scr, "copy failed: no clipboard") || !strings.Contains(scr, "kv-restricted: 403 Forbidden") {
|
||||||
|
t.Errorf("errors not listed:\n%s", scr)
|
||||||
|
}
|
||||||
|
if n := len(d.m.errLog); n != 2 || !strings.HasPrefix(d.m.errLog[0].text, "copy failed") {
|
||||||
|
t.Errorf("log %+v", d.m.errLog)
|
||||||
|
}
|
||||||
|
d.m.logErr("403 Forbidden: again") // a new one, and an old one again: on top
|
||||||
|
d.m.logErr(d.m.errLog[2].text)
|
||||||
|
if len(d.m.errLog) != 3 || d.m.errLog[0].text == "403 Forbidden: again" {
|
||||||
|
t.Errorf("log %+v", d.m.errLog)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The status pane lists this session's errors, newest first: what a pane
|
||||||
|
// showed (a list that couldn't load) and what the footer flashed (a copy
|
||||||
|
// that failed), which go when you move on. Only the first line of each,
|
||||||
|
// and never kept after quitting.
|
||||||
|
|
||||||
|
// maxErrors is how many errors are kept.
|
||||||
|
const maxErrors = 20
|
||||||
|
|
||||||
|
type loggedErr struct {
|
||||||
|
at time.Time
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// logErr records text; an error already there moves to the top, with
|
||||||
|
// the time it happened again.
|
||||||
|
func (m *Model) logErr(text string) {
|
||||||
|
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text })
|
||||||
|
m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// logErrOf records err, about what, except a sign-in, which the footer
|
||||||
|
// and the account's details say.
|
||||||
|
func (m *Model) logErrOf(what string, err error) {
|
||||||
|
var auth *provider.AuthError
|
||||||
|
if !errors.As(err, &auth) {
|
||||||
|
m.logErr(what + ": " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logFlash records an error the footer is showing, once.
|
||||||
|
func (m *Model) logFlash() {
|
||||||
|
if m.flash.isErr && m.flash.text != m.loggedFlash {
|
||||||
|
m.logErr(m.flash.text)
|
||||||
|
}
|
||||||
|
m.loggedFlash = m.flash.text
|
||||||
|
}
|
||||||
|
|
||||||
|
// errorFields are the status pane's rows for them.
|
||||||
|
func (m Model) errorFields() []panes.Field {
|
||||||
|
out := make([]panes.Field, len(m.errLog))
|
||||||
|
for i, e := range m.errLog {
|
||||||
|
out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true}
|
||||||
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
out[0].Label = "errors"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
// The fourth pane shows Fields for anything but a secret. On a provider's
|
// The fourth pane shows Fields for anything but a secret. On a provider's
|
||||||
// row: its account, from what the provider knows locally.
|
// row: its account, from what the provider knows locally.
|
||||||
|
|
||||||
// A vault's, scope's or file's details beyond its listing load once a
|
// A file's details beyond its listing load once a
|
||||||
// session, when the cursor rests on it, and again with refresh. They're
|
// session, when the cursor rests on it, and again with refresh. They're
|
||||||
// extra: moving on cancels the load, and search never waits for them.
|
// extra: moving on cancels the load, and search never waits for them.
|
||||||
type detailsState struct {
|
type detailsState struct {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package app
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@@ -244,6 +243,6 @@ func (m *Model) restorePlace() {
|
|||||||
|
|
||||||
func (m *Model) handlePersisted(msg persistedMsg) {
|
func (m *Model) handlePersisted(msg persistedMsg) {
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err))
|
m.logErrOf("saving the cache or place", msg.err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +102,7 @@ func (m Model) statusFields() []panes.Field {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out = append(out, m.expiringFields()...)
|
out = append(out, m.expiringFields()...)
|
||||||
|
out = append(out, m.errorFields()...)
|
||||||
|
|
||||||
for i, path := range m.configFiles {
|
for i, path := range m.configFiles {
|
||||||
label := ""
|
label := ""
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ func (d Detail) lines(st styles.Styles) []string {
|
|||||||
case ValueShown:
|
case ValueShown:
|
||||||
// Where the dots were, continuing under itself; the keys below.
|
// Where the dots were, continuing under itself; the keys below.
|
||||||
first := true
|
first := true
|
||||||
for _, line := range strings.Split(d.value.Reveal(), "\n") {
|
lines, note := valueView(d.value.Reveal())
|
||||||
|
for _, line := range lines {
|
||||||
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
|
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
|
||||||
if first {
|
if first {
|
||||||
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
|
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
|
||||||
@@ -324,6 +325,12 @@ func (d Detail) lines(st styles.Styles) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var more []string
|
var more []string
|
||||||
|
if note != "" && d.CopyKey != "" {
|
||||||
|
note += ": " + d.CopyKey + " copies it as stored"
|
||||||
|
}
|
||||||
|
if note != "" {
|
||||||
|
more = append(more, note)
|
||||||
|
}
|
||||||
if d.hidesIn != "" {
|
if d.hidesIn != "" {
|
||||||
more = append(more, "hides in "+d.hidesIn)
|
more = append(more, "hides in "+d.hidesIn)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package panes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha1"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A revealed value is shown as it reads best: JSON indented, and PEM
|
||||||
|
// certificates as what they say (subject, issuer, dates, names) rather
|
||||||
|
// than base64, with any other PEM block, a private key say, named but not
|
||||||
|
// shown. Anything else is shown as it is. y copies it as stored.
|
||||||
|
|
||||||
|
// valueView is how to show v, and what's been done to it ("" when it's
|
||||||
|
// shown as it is).
|
||||||
|
func valueView(v string) (lines []string, note string) {
|
||||||
|
if lines, ok := pemView(v); ok {
|
||||||
|
return lines, "certificate"
|
||||||
|
}
|
||||||
|
if t := strings.TrimSpace(v); (strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) && json.Valid([]byte(t)) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
if json.Indent(&b, []byte(t), "", " ") == nil {
|
||||||
|
return strings.Split(b.String(), "\n"), "JSON, formatted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Split(v, "\n"), ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// pemView describes the PEM blocks in v, if it's PEM with a certificate.
|
||||||
|
func pemView(v string) ([]string, bool) {
|
||||||
|
rest := []byte(strings.TrimSpace(v))
|
||||||
|
var out []string
|
||||||
|
certs := 0
|
||||||
|
for len(rest) > 0 {
|
||||||
|
block, after := pem.Decode(rest)
|
||||||
|
if block == nil {
|
||||||
|
return nil, false // text that isn't PEM: show it all
|
||||||
|
}
|
||||||
|
rest = bytes.TrimSpace(after)
|
||||||
|
if len(out) > 0 {
|
||||||
|
out = append(out, "")
|
||||||
|
}
|
||||||
|
if block.Type != "CERTIFICATE" {
|
||||||
|
out = append(out, block.Type+" (not shown)")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
certs++
|
||||||
|
out = append(out, certLines(cert)...)
|
||||||
|
}
|
||||||
|
return out, certs > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func certLines(c *x509.Certificate) []string {
|
||||||
|
day := func(t time.Time) string { return t.UTC().Format("2006-01-02") }
|
||||||
|
out := []string{
|
||||||
|
"subject " + c.Subject.String(),
|
||||||
|
"issuer " + c.Issuer.String(),
|
||||||
|
"valid " + day(c.NotBefore) + " to " + day(c.NotAfter),
|
||||||
|
}
|
||||||
|
names := append([]string{}, c.DNSNames...)
|
||||||
|
for _, ip := range c.IPAddresses {
|
||||||
|
names = append(names, ip.String())
|
||||||
|
}
|
||||||
|
names = append(names, c.EmailAddresses...)
|
||||||
|
if len(names) > 0 {
|
||||||
|
out = append(out, "names "+strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
sum := sha1.Sum(c.Raw) // what Azure and Windows call the thumbprint
|
||||||
|
return append(out, "SHA-1 "+fmt.Sprintf("%X", sum[:]))
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package panes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValueViewJSON(t *testing.T) {
|
||||||
|
lines, note := valueView(`{"user":"app","port":5432,"tags":["a"]}`)
|
||||||
|
want := []string{"{", ` "user": "app",`, ` "port": 5432,`, ` "tags": [`, ` "a"`, " ]", "}"}
|
||||||
|
if !slices.Equal(lines, want) || note != "JSON, formatted" {
|
||||||
|
t.Errorf("lines %q, note %q", lines, note)
|
||||||
|
}
|
||||||
|
for _, v := range []string{"plain value", "{not json", "42", "line one\nline two"} {
|
||||||
|
if lines, note := valueView(v); note != "" || strings.Join(lines, "\n") != v {
|
||||||
|
t.Errorf("%q shown as %q (%q)", v, lines, note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValueViewCertificate(t *testing.T) {
|
||||||
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "app.example.com"},
|
||||||
|
DNSNames: []string{"app.example.com", "www.app.example.com"},
|
||||||
|
NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||||
|
bundle := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +
|
||||||
|
string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||||
|
|
||||||
|
lines, note := valueView(bundle)
|
||||||
|
text := strings.Join(lines, "\n")
|
||||||
|
for _, want := range []string{"subject CN=app.example.com", "valid 2026-01-01 to 2027-01-01",
|
||||||
|
"names app.example.com, www.app.example.com", "SHA-1 ", "EC PRIVATE KEY (not shown)"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Errorf("missing %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if note != "certificate" || strings.Contains(text, "BEGIN") {
|
||||||
|
t.Errorf("note %q, or PEM shown:\n%s", note, text)
|
||||||
|
}
|
||||||
|
// A private key alone isn't a certificate: shown as it is.
|
||||||
|
if _, note := valueView(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))); note != "" {
|
||||||
|
t.Errorf("a key alone: note %q", note)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user