Skip to content

Commit afc67a6

Browse files
committed
feat(pass): add --metadata flag to pass set
The `pass set` command now accepts non-sensitive key=value metadata alongside the secret value via the new `--metadata` flag (repeatable): pass set docker/foo=bar --metadata owner=alice --metadata expiry=2027-03-01 When reading from STDIN, a JSON payload can carry both the secret and metadata in one input: echo '{"secret":"bar","metadata":{"owner":"alice"}}' | pass set docker/foo If both STDIN JSON metadata and --metadata flags are provided, the flag values take precedence on key collision. Plain STDIN input (non-JSON) is unchanged. Metadata is stored on PassValue via SetMetadata/Metadata, which were previously no-op stubs. Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
1 parent f4adbca commit afc67a6

File tree

3 files changed

+108
-10
lines changed

3 files changed

+108
-10
lines changed

plugins/pass/command_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,47 @@ func Test_rootCommand(t *testing.T) {
6969
require.True(t, ok)
7070
assert.Equal(t, "my\nmultiline\nvalue", string(impl.Value))
7171
})
72+
t.Run("with --metadata flag", func(t *testing.T) {
73+
mock := teststore.NewMockStore()
74+
out, err := executeCommand(Root(t.Context(), mock, mockInfo), "set", "foo=bar", "--metadata", "name=bob", "--metadata", "expiry=2027-03-01")
75+
assert.NoError(t, err)
76+
assert.Empty(t, out)
77+
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
78+
require.NoError(t, err)
79+
impl, ok := s.(*pass.PassValue)
80+
require.True(t, ok)
81+
assert.Equal(t, "bar", string(impl.Value))
82+
assert.Equal(t, map[string]string{"name": "bob", "expiry": "2027-03-01"}, impl.Metadata())
83+
})
84+
t.Run("from STDIN JSON with value and metadata", func(t *testing.T) {
85+
mock := teststore.NewMockStore()
86+
out, err := executeCommandWithStdin(Root(t.Context(), mock, mockInfo), `{"secret":"bar","metadata":{"name":"bob"}}`, "set", "foo")
87+
assert.NoError(t, err)
88+
assert.Empty(t, out)
89+
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
90+
require.NoError(t, err)
91+
impl, ok := s.(*pass.PassValue)
92+
require.True(t, ok)
93+
assert.Equal(t, "bar", string(impl.Value))
94+
assert.Equal(t, map[string]string{"name": "bob"}, impl.Metadata())
95+
})
96+
t.Run("from STDIN JSON merged with --metadata flag wins on collision", func(t *testing.T) {
97+
mock := teststore.NewMockStore()
98+
out, err := executeCommandWithStdin(Root(t.Context(), mock, mockInfo), `{"secret":"bar","metadata":{"name":"bob","extra":"thing"}}`, "set", "foo", "--metadata", "name=alice")
99+
assert.NoError(t, err)
100+
assert.Empty(t, out)
101+
s, err := mock.Get(t.Context(), secrets.MustParseID("foo"))
102+
require.NoError(t, err)
103+
impl, ok := s.(*pass.PassValue)
104+
require.True(t, ok)
105+
assert.Equal(t, "bar", string(impl.Value))
106+
assert.Equal(t, map[string]string{"name": "alice", "extra": "thing"}, impl.Metadata())
107+
})
108+
t.Run("invalid --metadata flag (no =)", func(t *testing.T) {
109+
mock := teststore.NewMockStore()
110+
_, err := executeCommand(Root(t.Context(), mock, mockInfo), "set", "foo=bar", "--metadata", "invalid")
111+
assert.ErrorContains(t, err, "invalid metadata pair (expected key=value): invalid")
112+
})
72113
t.Run("store error", func(t *testing.T) {
73114
errSave := errors.New("save error")
74115
mock := teststore.NewMockStore(teststore.WithStoreSaveErr(errSave))

plugins/pass/commands/set.go

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ package commands
1616

1717
import (
1818
"context"
19+
"encoding/json"
1920
"fmt"
2021
"io"
22+
"maps"
2123
"strings"
2224

2325
"github.com/spf13/cobra"
@@ -34,9 +36,25 @@ docker pass set POSTGRES_PASSWORD=my-secret-password
3436
# Or pass the secret via STDIN:
3537
echo my-secret-password > pwd.txt
3638
cat pwd.txt | docker pass set POSTGRES_PASSWORD
39+
40+
# Set a secret with metadata:
41+
docker pass set POSTGRES_PASSWORD=my-secret-password --metadata owner=alice --metadata expiry=2027-03-01
42+
43+
# Or pass a JSON payload with secret and metadata via STDIN:
44+
echo '{"secret":"my-secret-password","metadata":{"owner":"alice"}}' | docker pass set POSTGRES_PASSWORD
3745
`
3846

47+
type setOpts struct {
48+
metadata []string // raw "key=value" strings from --metadata flag
49+
}
50+
51+
type stdinPayload struct {
52+
Secret string `json:"secret"`
53+
Metadata map[string]string `json:"metadata,omitempty"`
54+
}
55+
3956
func SetCommand(kc store.Store) *cobra.Command {
57+
opts := setOpts{}
4058
cmd := &cobra.Command{
4159
Use: "set id[=value]",
4260
Aliases: []string{"store", "save"},
@@ -62,12 +80,47 @@ func SetCommand(kc store.Store) *cobra.Command {
6280
if err != nil {
6381
return err
6482
}
65-
return kc.Save(cmd.Context(), id, &pass.PassValue{Value: []byte(s.val)})
83+
84+
flagMeta, err := parseMetadataFlags(opts.metadata)
85+
if err != nil {
86+
return err
87+
}
88+
89+
// Merge: start with STDIN JSON metadata, override with flag metadata
90+
merged := maps.Clone(s.metadata)
91+
for k, v := range flagMeta {
92+
if merged == nil {
93+
merged = make(map[string]string)
94+
}
95+
merged[k] = v
96+
}
97+
98+
pv := &pass.PassValue{Value: []byte(s.val)}
99+
if len(merged) > 0 {
100+
if err := pv.SetMetadata(merged); err != nil {
101+
return err
102+
}
103+
}
104+
return kc.Save(cmd.Context(), id, pv)
66105
},
67106
}
107+
flags := cmd.Flags()
108+
flags.StringArrayVar(&opts.metadata, "metadata", nil, "Non-sensitive key=value metadata (repeatable)")
68109
return cmd
69110
}
70111

112+
func parseMetadataFlags(raw []string) (map[string]string, error) {
113+
m := make(map[string]string, len(raw))
114+
for _, kv := range raw {
115+
k, v, ok := strings.Cut(kv, "=")
116+
if !ok {
117+
return nil, fmt.Errorf("invalid metadata pair (expected key=value): %s", kv)
118+
}
119+
m[k] = v
120+
}
121+
return m, nil
122+
}
123+
71124
func isNotImplicitReadFromStdinSyntax(args []string) bool {
72125
return strings.Contains(args[0], "=") || len(args) > 1
73126
}
@@ -79,15 +132,17 @@ func secretMappingFromSTDIN(ctx context.Context, reader io.Reader, id string) (*
79132
}
80133
defer clear(data)
81134

82-
return &secret{
83-
id: id,
84-
val: string(data),
85-
}, nil
135+
var payload stdinPayload
136+
if err := json.Unmarshal(data, &payload); err == nil && payload.Secret != "" {
137+
return &secret{id: id, val: payload.Secret, metadata: payload.Metadata}, nil
138+
}
139+
return &secret{id: id, val: string(data)}, nil
86140
}
87141

88142
type secret struct {
89-
id string
90-
val string
143+
id string
144+
val string
145+
metadata map[string]string
91146
}
92147

93148
func parseArg(arg string) (*secret, error) {

plugins/pass/store/store.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import (
2424
var _ store.Secret = &PassValue{}
2525

2626
type PassValue struct {
27-
Value []byte `json:"value"`
27+
Value []byte `json:"value"`
28+
metadata map[string]string // not exported; populated via SetMetadata
2829
}
2930

3031
func (m *PassValue) Marshal() ([]byte, error) {
@@ -37,10 +38,11 @@ func (m *PassValue) Unmarshal(data []byte) error {
3738
}
3839

3940
func (m *PassValue) Metadata() map[string]string {
40-
return nil
41+
return m.metadata
4142
}
4243

43-
func (m *PassValue) SetMetadata(map[string]string) error {
44+
func (m *PassValue) SetMetadata(md map[string]string) error {
45+
m.metadata = md
4446
return nil
4547
}
4648

0 commit comments

Comments
 (0)