Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 61 additions & 10 deletions plugins/pass/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ func Test_rootCommand(t *testing.T) {
require.NoError(t, err)
impl, ok := s.(*pass.PassValue)
require.True(t, ok)
assert.Equal(t, "bar=bar=bar", string(impl.Value))
v, err := impl.Marshal()
require.NoError(t, err)
assert.Equal(t, "bar=bar=bar", string(v))
})
t.Run("from STDIN", func(t *testing.T) {
mock := teststore.NewMockStore()
Expand All @@ -67,7 +69,56 @@ func Test_rootCommand(t *testing.T) {
require.NoError(t, err)
impl, ok := s.(*pass.PassValue)
require.True(t, ok)
assert.Equal(t, "my\nmultiline\nvalue", string(impl.Value))
v, err := impl.Marshal()
require.NoError(t, err)
assert.Equal(t, "my\nmultiline\nvalue", string(v))
})
t.Run("with --metadata flag", func(t *testing.T) {
mock := teststore.NewMockStore()
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "set", "foo=bar", "--metadata", "name=bob", "--metadata", "expiry=2027-03-01")
assert.NoError(t, err)
assert.Empty(t, out)
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
require.NoError(t, err)
impl, ok := s.(*pass.PassValue)
require.True(t, ok)
v, err := impl.Marshal()
require.NoError(t, err)
assert.Equal(t, "bar", string(v))
assert.Equal(t, map[string]string{"name": "bob", "expiry": "2027-03-01"}, impl.Metadata())
})
t.Run("from STDIN JSON with value and metadata", func(t *testing.T) {
mock := teststore.NewMockStore()
out, err := executeCommandWithStdin(Root(t.Context(), mock, mockInfo), `{"secret":"bar","metadata":{"name":"bob"}}`, "set", "foo")
assert.NoError(t, err)
assert.Empty(t, out)
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
require.NoError(t, err)
impl, ok := s.(*pass.PassValue)
require.True(t, ok)
v, err := impl.Marshal()
require.NoError(t, err)
assert.Equal(t, "bar", string(v))
assert.Equal(t, map[string]string{"name": "bob"}, impl.Metadata())
})
t.Run("from STDIN JSON merged with --metadata flag wins on collision", func(t *testing.T) {
mock := teststore.NewMockStore()
out, err := executeCommandWithStdin(Root(t.Context(), mock, mockInfo), `{"secret":"bar","metadata":{"name":"bob","extra":"thing"}}`, "set", "foo", "--metadata", "name=alice")
assert.NoError(t, err)
assert.Empty(t, out)
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
require.NoError(t, err)
impl, ok := s.(*pass.PassValue)
require.True(t, ok)
v, err := impl.Marshal()
require.NoError(t, err)
assert.Equal(t, "bar", string(v))
assert.Equal(t, map[string]string{"name": "alice", "extra": "thing"}, impl.Metadata())
})
t.Run("invalid --metadata flag (no =)", func(t *testing.T) {
mock := teststore.NewMockStore()
_, err := executeCommand(Root(t.Context(), mock, mockInfo), "set", "foo=bar", "--metadata", "invalid")
assert.ErrorContains(t, err, "invalid metadata pair (expected key=value): invalid")
})
t.Run("store error", func(t *testing.T) {
errSave := errors.New("save error")
Expand All @@ -88,8 +139,8 @@ func Test_rootCommand(t *testing.T) {
t.Run("list", func(t *testing.T) {
t.Run("ok", func(t *testing.T) {
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
store.MustParseID("baz"): &pass.PassValue{Value: []byte("0")},
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
store.MustParseID("baz"): pass.NewPassValue([]byte("0")),
}))
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "list")
assert.NoError(t, err)
Expand All @@ -106,8 +157,8 @@ func Test_rootCommand(t *testing.T) {
t.Run("rm", func(t *testing.T) {
t.Run("ok (two secrets)", func(t *testing.T) {
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
store.MustParseID("baz"): &pass.PassValue{Value: []byte("0")},
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
store.MustParseID("baz"): pass.NewPassValue([]byte("0")),
}))
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "rm", "foo", "baz")
assert.NoError(t, err)
Expand All @@ -118,8 +169,8 @@ func Test_rootCommand(t *testing.T) {
})
t.Run("--all", func(t *testing.T) {
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
store.MustParseID("baz"): &pass.PassValue{Value: []byte("0")},
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
store.MustParseID("baz"): pass.NewPassValue([]byte("0")),
}))
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "rm", "--all")
assert.NoError(t, err)
Expand Down Expand Up @@ -158,7 +209,7 @@ func Test_rootCommand(t *testing.T) {
t.Run("get", func(t *testing.T) {
t.Run("ok", func(t *testing.T) {
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
}))
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "get", "foo")
assert.NoError(t, err)
Expand Down Expand Up @@ -200,7 +251,7 @@ func Test_rootCommandTelemetry(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
spanRecorder, metricReader := testhelper.SetupTelemetry(t)
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
store.MustParseID("baz"): &pass.PassValue{Value: []byte("bar")},
store.MustParseID("baz"): pass.NewPassValue([]byte("bar")),
}))
_, err := executeCommand(Root(t.Context(), mock, mockInfo), tc.args...)
assert.NoError(t, err)
Expand Down
72 changes: 65 additions & 7 deletions plugins/pass/commands/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ package commands

import (
"context"
"encoding/json"
"fmt"
"io"
"maps"
"strings"

"github.com/spf13/cobra"
Expand All @@ -34,9 +36,25 @@ docker pass set POSTGRES_PASSWORD=my-secret-password
# Or pass the secret via STDIN:
echo my-secret-password > pwd.txt
cat pwd.txt | docker pass set POSTGRES_PASSWORD

# Set a secret with metadata:
docker pass set POSTGRES_PASSWORD=my-secret-password --metadata owner=alice --metadata expiry=2027-03-01

# Or pass a JSON payload with secret and metadata via STDIN:
echo '{"secret":"my-secret-password","metadata":{"owner":"alice"}}' | docker pass set POSTGRES_PASSWORD
`

type setOpts struct {
metadata []string // raw "key=value" strings from --metadata flag
}

type stdinPayload struct {
Secret string `json:"secret"`
Metadata map[string]string `json:"metadata,omitempty"`
}

func SetCommand(kc store.Store) *cobra.Command {
opts := setOpts{}
cmd := &cobra.Command{
Use: "set id[=value]",
Aliases: []string{"store", "save"},
Expand All @@ -62,12 +80,50 @@ func SetCommand(kc store.Store) *cobra.Command {
if err != nil {
return err
}
return kc.Save(cmd.Context(), id, &pass.PassValue{Value: []byte(s.val)})

flagMeta, err := parseMetadataFlags(opts.metadata)
if err != nil {
return err
}

// Merge: start with STDIN JSON metadata, override with flag metadata
merged := maps.Clone(s.metadata)
for k, v := range flagMeta {
if merged == nil {
merged = make(map[string]string)
}
merged[k] = v
}

pv := &pass.PassValue{}
if err := pv.Unmarshal([]byte(s.val)); err != nil {
return err
}
if len(merged) > 0 {
if err := pv.SetMetadata(merged); err != nil {
return err
}
}
return kc.Save(cmd.Context(), id, pv)
},
}
flags := cmd.Flags()
flags.StringArrayVar(&opts.metadata, "metadata", nil, "Non-sensitive key=value metadata (repeatable)")
return cmd
}

func parseMetadataFlags(raw []string) (map[string]string, error) {
m := make(map[string]string, len(raw))
for _, kv := range raw {
k, v, ok := strings.Cut(kv, "=")
if !ok {
return nil, fmt.Errorf("invalid metadata pair (expected key=value): %s", kv)
}
m[k] = v
}
return m, nil
}

func isNotImplicitReadFromStdinSyntax(args []string) bool {
return strings.Contains(args[0], "=") || len(args) > 1
}
Expand All @@ -79,15 +135,17 @@ func secretMappingFromSTDIN(ctx context.Context, reader io.Reader, id string) (*
}
defer clear(data)

return &secret{
id: id,
val: string(data),
}, nil
var payload stdinPayload
if err := json.Unmarshal(data, &payload); err == nil && payload.Secret != "" {
return &secret{id: id, val: payload.Secret, metadata: payload.Metadata}, nil
}
return &secret{id: id, val: string(data)}, nil
}

type secret struct {
id string
val string
id string
val string
metadata map[string]string
}

func parseArg(arg string) (*secret, error) {
Expand Down
6 changes: 5 additions & 1 deletion plugins/pass/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,13 @@ func unpackValue(id store.ID, secret store.Secret) (*plugin.Envelope, error) {
if !ok {
return nil, errUnknownSecretType
}
value, err := impl.Marshal()
if err != nil {
return nil, err
}
return &plugin.Envelope{
ID: id,
Value: impl.Value,
Value: value,
}, nil
}

Expand Down
2 changes: 1 addition & 1 deletion plugins/pass/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func Test_passPlugin(t *testing.T) {
t.Parallel()
t.Run("ok", func(t *testing.T) {
mock := teststore.NewMockStore(teststore.WithStore(map[store.ID]store.Secret{
store.MustParseID("foo"): &pass.PassValue{Value: []byte("bar")},
store.MustParseID("foo"): pass.NewPassValue([]byte("bar")),
}))
p := &passPlugin{kc: mock, logger: testhelper.TestLogger(t)}
e, err := p.GetSecrets(t.Context(), secrets.MustParsePattern("foo"))
Expand Down
16 changes: 11 additions & 5 deletions plugins/pass/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,29 @@ import (
var _ store.Secret = &PassValue{}

type PassValue struct {
Value []byte `json:"value"`
value []byte
metadata map[string]string // not exported; populated via SetMetadata
}

func NewPassValue(value []byte) *PassValue {
return &PassValue{value: value}
}

func (m *PassValue) Marshal() ([]byte, error) {
return m.Value, nil
return m.value, nil
}

func (m *PassValue) Unmarshal(data []byte) error {
m.Value = data
m.value = data
return nil
}

func (m *PassValue) Metadata() map[string]string {
return nil
return m.metadata
}

func (m *PassValue) SetMetadata(map[string]string) error {
func (m *PassValue) SetMetadata(md map[string]string) error {
m.metadata = md
return nil
}

Expand Down
Loading