Skip to content

Commit cffebae

Browse files
committed
fix(flags): reject an empty element in a multi-value flag
pflag's StringSlice splits each value with a CSV reader, and that reader yields nothing for an empty string. An empty element is therefore appended as nothing and leaves no trace: after parsing, `--attachments "" --attachments file` cannot be told apart from `--attachments file`, so nothing downstream can report what was lost. Two flags where that matters now use a value type that refuses an empty element at Set, the last point where it still exists. --attachments is the case this bug was filed for: an attestation is recorded without evidence its author believed was on it. --template is worse in kind, because it names the attestations a flow requires, so a dropped element weakens that flow for every artifact passing through it afterwards rather than spoiling one record. Comma splitting is kept rather than switching to pflag's StringArray, which stores values verbatim. An environment variable cannot be repeated, so a comma list is the only way to give a multi-value flag more than one value from the environment; a regression test pins that. The type implements pflag.SliceValue as well as pflag.Value, so the refusal also covers the config file, which bindFlags applies through Replace.
1 parent 2ee6268 commit cffebae

6 files changed

Lines changed: 248 additions & 2 deletions

File tree

‎cmd/kosli/attestGeneric_test.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"testing"
66

7+
"github.com/stretchr/testify/require"
78
"github.com/stretchr/testify/suite"
89
)
910

@@ -203,6 +204,28 @@ func (suite *AttestGenericCommandTestSuite) TestAttestGenericCmd() {
203204
runTestCmd(suite.T(), tests)
204205
}
205206

207+
// TestAttestGenericRejectsEmptyAttachment pins the case this issue was filed
208+
// for. `--attachments "$FILE" --attachments provenance.json` with FILE unset
209+
// attaches only provenance.json: the empty element is discarded while the flag
210+
// still counts as set, so the attestation is recorded without evidence its
211+
// author believed was on it, and nothing in the output says so.
212+
//
213+
// The surviving attachment is a real file, so that before the refusal exists
214+
// the command succeeds rather than failing to stat a missing path - otherwise
215+
// this test would pass for the wrong reason.
216+
//
217+
// This is deliberately not part of AttestGenericCommandTestSuite: the refusal
218+
// happens while flags are parsed, before any request, so it needs no server.
219+
func TestAttestGenericRejectsEmptyAttachment(t *testing.T) {
220+
_, _, _, _, err := executeCommandC(
221+
`attest generic --attachments "" --attachments testdata/file1 ` +
222+
`--fingerprint 0000000000000000000000000000000000000000000000000000000000000001 ` +
223+
`--name foo --flow f --trail t --org demo --api-token DRY_RUN --dry-run`)
224+
225+
require.Error(t, err)
226+
require.ErrorContains(t, err, "attachments")
227+
}
228+
206229
// In order for 'go test' to run this suite, we need to create
207230
// a normal test function and pass our suite to suite.Run
208231
func TestAttestGenericCommandTestSuite(t *testing.T) {

‎cmd/kosli/createFlow.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func newCreateFlowCmd(out io.Writer) *cobra.Command {
8787

8888
cmd.Flags().StringVar(&o.payload.Description, "description", "", flowDescriptionFlag)
8989
cmd.Flags().StringVar(&o.payload.Visibility, "visibility", "", visibilityFlag)
90-
cmd.Flags().StringSliceVarP(&o.payload.Template, "template", "t", []string{}, templateFlag)
90+
cmd.Flags().VarP(newNonEmptyStringSlice(&o.payload.Template), "template", "t", templateFlag)
9191
cmd.Flags().StringVarP(&o.TemplateFile, "template-file", "f", "", templateFileFlag)
9292
cmd.Flags().BoolVar(&o.UseEmptyTemplate, "use-empty-template", false, useEmptyTemplateFlag)
9393
addDryRunFlag(cmd)

‎cmd/kosli/createFlow_test.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"testing"
66

7+
"github.com/stretchr/testify/require"
78
"github.com/stretchr/testify/suite"
89
)
910

@@ -112,6 +113,23 @@ func (suite *CreateFlowCommandTestSuite) TestCreateFlowCmd() {
112113
runTestCmd(suite.T(), tests)
113114
}
114115

116+
// TestCreateFlowRejectsEmptyTemplateElement pins that an empty --template
117+
// element is refused rather than dropped. --template names the attestations a
118+
// flow requires, so an element lost on the way in weakens the flow's template
119+
// for every artifact that passes through it afterwards, long after the run that
120+
// caused it. `-t "$COVERAGE" -t unit-test` with COVERAGE unset is the shape
121+
// that does it.
122+
//
123+
// This is deliberately not part of CreateFlowCommandTestSuite: the rejection
124+
// happens while flags are parsed, before any request, so it needs no server.
125+
func TestCreateFlowRejectsEmptyTemplateElement(t *testing.T) {
126+
_, _, _, _, err := executeCommandC(
127+
`create flow myflow -t "" -t unit-test --org demo --api-token DRY_RUN --dry-run`)
128+
129+
require.Error(t, err)
130+
require.ErrorContains(t, err, "template")
131+
}
132+
115133
// In order for 'go test' to run this suite, we need to create
116134
// a normal test function and pass our suite to suite.Run
117135
func TestCreateFlowCommandTestSuite(t *testing.T) {

‎cmd/kosli/flags.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func addAttestationFlags(cmd *cobra.Command, o *CommonAttestationOptions, payloa
110110
cmd.Flags().StringVarP(&o.flowName, "flow", "f", "", flowNameFlag)
111111
cmd.Flags().StringVarP(&o.trailName, "trail", "T", "", trailNameFlag)
112112
cmd.Flags().StringVarP(&o.userDataFilePath, "user-data", "u", "", attestationUserDataFlag)
113-
cmd.Flags().StringSliceVar(&o.attachments, "attachments", []string{}, attachmentsFlag)
113+
cmd.Flags().Var(newNonEmptyStringSlice(&o.attachments), "attachments", attachmentsFlag)
114114
cmd.Flags().StringVar(&o.srcRepoRoot, "repo-root", ".", attestationRepoRootFlag)
115115
cmd.Flags().StringVar(&payload.Description, "description", "", attestationDescription)
116116
cmd.Flags().StringVar(&o.repoID, "repo-id", DefaultValue(ci, "repo-id"), repoIDFlag)

‎cmd/kosli/nonEmptyStringSlice.go‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package main
2+
3+
import (
4+
"encoding/csv"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/spf13/pflag"
9+
)
10+
11+
// SliceValue is satisfied by type assertion at the point of use: bindFlags
12+
// applies a config file list through Replace, and a post-parse check reads
13+
// GetSlice. Nothing enforces it, so a drifting signature would silently fall
14+
// back to the string path instead of failing. This assertion makes that a
15+
// compile error. pflag.Value needs no such assertion - the Flags().VarP call
16+
// registering the flag already requires it.
17+
var _ pflag.SliceValue = (*nonEmptyStringSlice)(nil)
18+
19+
// nonEmptyStringSlice holds the values of a multi-value flag and refuses an
20+
// empty one.
21+
//
22+
// pflag's own StringSlice splits each value with a CSV reader, which yields
23+
// nothing at all for an empty string. An empty element is therefore appended as
24+
// nothing and leaves no trace: after parsing, `--attachments "" --attachments
25+
// provenance.json` cannot be told apart from `--attachments provenance.json`.
26+
// Set is the last point at which the element still exists, so it is where the
27+
// refusal has to happen.
28+
//
29+
// The comma splitting is kept rather than switching to pflag's StringArray,
30+
// which stores values verbatim. An environment variable cannot be repeated, so
31+
// a comma list is the only way to give a multi-value flag more than one value
32+
// from the environment.
33+
type nonEmptyStringSlice struct {
34+
values *[]string
35+
changed bool
36+
}
37+
38+
// newNonEmptyStringSlice returns a flag value writing through to values.
39+
func newNonEmptyStringSlice(values *[]string) *nonEmptyStringSlice {
40+
return &nonEmptyStringSlice{values: values}
41+
}
42+
43+
// Append adds one value, refusing an empty one. It is part of pflag.SliceValue.
44+
func (s *nonEmptyStringSlice) Append(value string) error {
45+
if value == "" {
46+
return fmt.Errorf("empty values are not allowed")
47+
}
48+
*s.values = append(*s.values, value)
49+
s.changed = true
50+
return nil
51+
}
52+
53+
// Replace overwrites every value, refusing an empty one. It is part of
54+
// pflag.SliceValue, and it is the path a config file list arrives by, so the
55+
// refusal has to hold here as well as in Set. Checking only Set would leave the
56+
// config file as a way in for the values the flag refuses on the command line.
57+
func (s *nonEmptyStringSlice) Replace(values []string) error {
58+
for _, value := range values {
59+
if value == "" {
60+
return fmt.Errorf("empty values are not allowed")
61+
}
62+
}
63+
*s.values = values
64+
s.changed = true
65+
return nil
66+
}
67+
68+
// GetSlice returns the values as separate elements. It is part of
69+
// pflag.SliceValue, and it is what lets a post-parse check see the elements
70+
// rather than the bracketed String form.
71+
func (s *nonEmptyStringSlice) GetSlice() []string {
72+
return *s.values
73+
}
74+
75+
// String renders the values the way pflag's own StringSlice does, so help text
76+
// and defaults read identically for a flag that switches to this type.
77+
func (s *nonEmptyStringSlice) String() string {
78+
return "[" + strings.Join(*s.values, ",") + "]"
79+
}
80+
81+
// Type names the type shown in help text. It matches pflag's StringSlice so a
82+
// flag that switches to this type keeps the same help output.
83+
func (s *nonEmptyStringSlice) Type() string {
84+
return "stringSlice"
85+
}
86+
87+
// Set splits value on commas and stores the result, replacing whatever the flag
88+
// held on the first call and appending on later ones, which is how pflag
89+
// accumulates a repeated flag. It returns an error when value is empty or
90+
// contains an empty element. pflag prefixes that error with the flag name.
91+
func (s *nonEmptyStringSlice) Set(value string) error {
92+
if value == "" {
93+
return fmt.Errorf("empty values are not allowed")
94+
}
95+
96+
elements, err := csv.NewReader(strings.NewReader(value)).Read()
97+
if err != nil {
98+
return err
99+
}
100+
for _, element := range elements {
101+
if element == "" {
102+
return fmt.Errorf("empty values are not allowed")
103+
}
104+
}
105+
106+
if s.changed {
107+
*s.values = append(*s.values, elements...)
108+
} else {
109+
*s.values = elements
110+
s.changed = true
111+
}
112+
113+
return nil
114+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
// TestNonEmptyStringSliceRejectsAnEmptyElement pins the whole point of the
10+
// type. pflag's own StringSlice runs each value through a CSV reader, and that
11+
// reader yields nothing for an empty string, so an empty element is appended as
12+
// nothing and leaves no trace behind. Refusing it at Set is the only place the
13+
// element still exists to be seen.
14+
func TestNonEmptyStringSliceRejectsAnEmptyElement(t *testing.T) {
15+
for _, value := range []string{"", "a,,b", ",a", "a,"} {
16+
t.Run(value, func(t *testing.T) {
17+
var values []string
18+
slice := newNonEmptyStringSlice(&values)
19+
20+
require.Error(t, slice.Set(value))
21+
})
22+
}
23+
}
24+
25+
// TestNonEmptyStringSliceKeepsCommaSplitting pins the behaviour that stops this
26+
// being solved with pflag's StringArray instead. An environment variable cannot
27+
// be repeated, so a comma list is the only way to give a multi-value flag more
28+
// than one value from the environment.
29+
func TestNonEmptyStringSliceKeepsCommaSplitting(t *testing.T) {
30+
var values []string
31+
slice := newNonEmptyStringSlice(&values)
32+
33+
require.NoError(t, slice.Set("a,b"))
34+
35+
require.Equal(t, []string{"a", "b"}, values)
36+
}
37+
38+
// TestNonEmptyStringSliceReplaceRejectsAnEmptyElement pins that the refusal
39+
// covers the pflag.SliceValue path as well as Set. bindFlags applies a config
40+
// file list through Replace, so a type that checked only Set would leave the
41+
// config file as a way in for exactly the values the flag refuses on the
42+
// command line.
43+
func TestNonEmptyStringSliceReplaceRejectsAnEmptyElement(t *testing.T) {
44+
var values []string
45+
slice := newNonEmptyStringSlice(&values)
46+
47+
require.Error(t, slice.Replace([]string{"a", "", "b"}))
48+
}
49+
50+
// TestNonEmptyStringSliceReplaceOverwrites pins that Replace discards whatever
51+
// the flag held, which is what pflag documents it to do.
52+
func TestNonEmptyStringSliceReplaceOverwrites(t *testing.T) {
53+
var values []string
54+
slice := newNonEmptyStringSlice(&values)
55+
require.NoError(t, slice.Set("a"))
56+
57+
require.NoError(t, slice.Replace([]string{"b", "c"}))
58+
59+
require.Equal(t, []string{"b", "c"}, values)
60+
}
61+
62+
// TestNonEmptyStringSliceAppendRejectsAnEmptyElement pins the same refusal on
63+
// the remaining pflag.SliceValue method.
64+
func TestNonEmptyStringSliceAppendRejectsAnEmptyElement(t *testing.T) {
65+
var values []string
66+
slice := newNonEmptyStringSlice(&values)
67+
68+
require.Error(t, slice.Append(""))
69+
}
70+
71+
// TestNonEmptyStringSliceGetSliceReturnsTheValues pins the accessor that lets a
72+
// post-parse check see the elements, rather than the bracketed String form.
73+
func TestNonEmptyStringSliceGetSliceReturnsTheValues(t *testing.T) {
74+
var values []string
75+
slice := newNonEmptyStringSlice(&values)
76+
require.NoError(t, slice.Set("a,b"))
77+
78+
require.Equal(t, []string{"a", "b"}, slice.GetSlice())
79+
}
80+
81+
// TestNonEmptyStringSliceAppendsOnRepeatedSet pins that a flag given more than
82+
// once accumulates its values, which is how pflag applies a repeated flag.
83+
func TestNonEmptyStringSliceAppendsOnRepeatedSet(t *testing.T) {
84+
var values []string
85+
slice := newNonEmptyStringSlice(&values)
86+
87+
require.NoError(t, slice.Set("a"))
88+
require.NoError(t, slice.Set("b,c"))
89+
90+
require.Equal(t, []string{"a", "b", "c"}, values)
91+
}

0 commit comments

Comments
 (0)