diff --git a/plugins/pass/command_test.go b/plugins/pass/command_test.go index 7a7aeaef..cb288c85 100644 --- a/plugins/pass/command_test.go +++ b/plugins/pass/command_test.go @@ -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() @@ -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") @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/plugins/pass/commands/set.go b/plugins/pass/commands/set.go index 67c7eba4..429fc7d7 100644 --- a/plugins/pass/commands/set.go +++ b/plugins/pass/commands/set.go @@ -16,8 +16,10 @@ package commands import ( "context" + "encoding/json" "fmt" "io" + "maps" "strings" "github.com/spf13/cobra" @@ -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"}, @@ -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 } @@ -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) { diff --git a/plugins/pass/plugin.go b/plugins/pass/plugin.go index 29ee4947..1a94e963 100644 --- a/plugins/pass/plugin.go +++ b/plugins/pass/plugin.go @@ -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 } diff --git a/plugins/pass/plugin_test.go b/plugins/pass/plugin_test.go index 446f86e8..4c970ebf 100644 --- a/plugins/pass/plugin_test.go +++ b/plugins/pass/plugin_test.go @@ -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")) diff --git a/plugins/pass/store/store.go b/plugins/pass/store/store.go index d84a6564..2d0bf9cd 100644 --- a/plugins/pass/store/store.go +++ b/plugins/pass/store/store.go @@ -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 }