diff --git a/expfmt/bench_test.go b/expfmt/bench_test.go index 4f691b310..90d9373d9 100644 --- a/expfmt/bench_test.go +++ b/expfmt/bench_test.go @@ -26,9 +26,11 @@ import ( dto "github.com/prometheus/client_model/go" "github.com/stretchr/testify/require" + + "github.com/prometheus/common/model" ) -var parser TextParser +var parser = TextParser{scheme: model.UTF8Validation} // Benchmarks to show how much penalty text format parsing actually inflicts. // diff --git a/expfmt/decode.go b/expfmt/decode.go index 24a24b53a..c4a701c50 100644 --- a/expfmt/decode.go +++ b/expfmt/decode.go @@ -70,19 +70,34 @@ func ResponseFormat(h http.Header) Format { return FmtUnknown } -// NewDecoder returns a new decoder based on the given input format. -// If the input format does not imply otherwise, a text format decoder is returned. +// NewDecoder returns a new decoder based on the given input format. Metric +// names are validated based on the provided Format -- if the format requires +// escaping, raditional Prometheues validity checking is used. Otherwise, names +// are checked for UTF-8 validity. Supported formats include delimited protobuf +// and Prometheus text format. For historical reasons, this decoder fallbacks +// to classic text decoding for any other format. This decoder does not fully +// support OpenMetrics although it may often succeed due to the similarities +// between the formats. This decoder may not support the latest features of +// Prometheus text format and is not intended for high-performance applications. +// See: https://github.com/prometheus/common/issues/812 func NewDecoder(r io.Reader, format Format) Decoder { + scheme := model.LegacyValidation + if format.ToEscapingScheme() == model.NoEscaping { + scheme = model.UTF8Validation + } switch format.FormatType() { case TypeProtoDelim: - return &protoDecoder{r: bufio.NewReader(r)} + return &protoDecoder{r: bufio.NewReader(r), s: scheme} + case TypeProtoText, TypeProtoCompact: + return &errDecoder{err: fmt.Errorf("format %s not supported for decoding", format)} } - return &textDecoder{r: r} + return &textDecoder{r: r, s: scheme} } // protoDecoder implements the Decoder interface for protocol buffers. type protoDecoder struct { r protodelim.Reader + s model.ValidationScheme } // Decode implements the Decoder interface. @@ -93,8 +108,7 @@ func (d *protoDecoder) Decode(v *dto.MetricFamily) error { if err := opts.UnmarshalFrom(d.r, v); err != nil { return err } - //nolint:staticcheck // model.IsValidMetricName is deprecated. - if !model.IsValidMetricName(model.LabelValue(v.GetName())) { + if !d.s.IsValidMetricName(v.GetName()) { return fmt.Errorf("invalid metric name %q", v.GetName()) } for _, m := range v.GetMetric() { @@ -108,8 +122,7 @@ func (d *protoDecoder) Decode(v *dto.MetricFamily) error { if !model.LabelValue(l.GetValue()).IsValid() { return fmt.Errorf("invalid label value %q", l.GetValue()) } - //nolint:staticcheck // model.LabelName.IsValid is deprecated. - if !model.LabelName(l.GetName()).IsValid() { + if !d.s.IsValidLabelName(l.GetName()) { return fmt.Errorf("invalid label name %q", l.GetName()) } } @@ -117,10 +130,20 @@ func (d *protoDecoder) Decode(v *dto.MetricFamily) error { return nil } +// errDecoder is an error-state decoder that always returns the same error. +type errDecoder struct { + err error +} + +func (d *errDecoder) Decode(v *dto.MetricFamily) error { + return d.err +} + // textDecoder implements the Decoder interface for the text protocol. type textDecoder struct { r io.Reader fams map[string]*dto.MetricFamily + s model.ValidationScheme err error } @@ -128,7 +151,7 @@ type textDecoder struct { func (d *textDecoder) Decode(v *dto.MetricFamily) error { if d.err == nil { // Read all metrics in one shot. - var p TextParser + p := TextParser{scheme: d.s} d.fams, d.err = p.TextToMetricFamilies(d.r) // If we don't get an error, store io.EOF for the end. if d.err == nil { diff --git a/expfmt/decode_test.go b/expfmt/decode_test.go index 759ff7461..baa761db1 100644 --- a/expfmt/decode_test.go +++ b/expfmt/decode_test.go @@ -80,7 +80,10 @@ mf2 4 ) dec := &SampleDecoder{ - Dec: &textDecoder{r: strings.NewReader(in)}, + Dec: &textDecoder{ + s: model.UTF8Validation, + r: strings.NewReader(in), + }, Opts: &DecodeOptions{ Timestamp: ts, }, @@ -361,7 +364,7 @@ func TestProtoDecoder(t *testing.T) { for i, scenario := range scenarios { dec := &SampleDecoder{ - Dec: &protoDecoder{r: strings.NewReader(scenario.in)}, + Dec: &protoDecoder{r: strings.NewReader(scenario.in), s: model.LegacyValidation}, Opts: &DecodeOptions{ Timestamp: testTime, }, @@ -369,7 +372,6 @@ func TestProtoDecoder(t *testing.T) { var all model.Vector for { - model.NameValidationScheme = model.LegacyValidation //nolint:staticcheck var smpls model.Vector err := dec.Decode(&smpls) if err != nil && errors.Is(err, io.EOF) { @@ -377,9 +379,8 @@ func TestProtoDecoder(t *testing.T) { } if scenario.legacyNameFail { require.Errorf(t, err, "Expected error when decoding without UTF-8 support enabled but got none") - model.NameValidationScheme = model.UTF8Validation //nolint:staticcheck dec = &SampleDecoder{ - Dec: &protoDecoder{r: strings.NewReader(scenario.in)}, + Dec: &protoDecoder{r: strings.NewReader(scenario.in), s: model.UTF8Validation}, Opts: &DecodeOptions{ Timestamp: testTime, }, diff --git a/expfmt/encode_test.go b/expfmt/encode_test.go index b7d3418f7..ea3cbfac7 100644 --- a/expfmt/encode_test.go +++ b/expfmt/encode_test.go @@ -376,3 +376,93 @@ func TestEscapedEncode(t *testing.T) { }) } } + +func TestDottedEncode(t *testing.T) { + //nolint:staticcheck + model.NameValidationScheme = model.UTF8Validation + metric := &dto.MetricFamily{ + Name: proto.String("foo.metric"), + Type: dto.MetricType_COUNTER.Enum(), + Metric: []*dto.Metric{ + { + Counter: &dto.Counter{ + Value: proto.Float64(1.234), + }, + }, + { + Label: []*dto.LabelPair{ + { + Name: proto.String("dotted.label.name"), + Value: proto.String("my.label.value"), + }, + }, + Counter: &dto.Counter{ + Value: proto.Float64(8), + }, + }, + }, + } + + scenarios := []struct { + format Format + expectMetricName string + expectLabelName string + }{ + { + format: FmtProtoDelim, + expectMetricName: "foo_metric", + expectLabelName: "dotted_label_name", + }, + { + format: FmtProtoDelim.WithEscapingScheme(model.NoEscaping), + expectMetricName: "foo.metric", + expectLabelName: "dotted.label.name", + }, + { + format: FmtProtoDelim.WithEscapingScheme(model.DotsEscaping), + expectMetricName: "foo_dot_metric", + expectLabelName: "dotted_dot_label_dot_name", + }, + { + format: FmtText, + expectMetricName: "foo_metric", + expectLabelName: "dotted_label_name", + }, + { + format: FmtText.WithEscapingScheme(model.NoEscaping), + expectMetricName: "foo.metric", + expectLabelName: "dotted.label.name", + }, + { + format: FmtText.WithEscapingScheme(model.DotsEscaping), + expectMetricName: "foo_dot_metric", + expectLabelName: "dotted_dot_label_dot_name", + }, + // common library does not support proto text or open metrics parsing so we + // do not test those here. + } + + for i, scenario := range scenarios { + out := bytes.NewBuffer(make([]byte, 0)) + enc := NewEncoder(out, scenario.format) + err := enc.Encode(metric) + if err != nil { + t.Errorf("%d. error: %s", i, err) + continue + } + + dec := NewDecoder(bytes.NewReader(out.Bytes()), scenario.format) + var gotFamily dto.MetricFamily + err = dec.Decode(&gotFamily) + if err != nil { + t.Errorf("%v: unexpected error during decode: %s", scenario.format, err.Error()) + } + if gotFamily.GetName() != scenario.expectMetricName { + t.Errorf("%v: incorrect encoded metric name, want %v, got %v", scenario.format, scenario.expectMetricName, gotFamily.GetName()) + } + lName := gotFamily.GetMetric()[1].Label[0].GetName() + if lName != scenario.expectLabelName { + t.Errorf("%v: incorrect encoded label name, want %v, got %v", scenario.format, scenario.expectLabelName, lName) + } + } +} diff --git a/expfmt/text_parse.go b/expfmt/text_parse.go index 4067978a1..4799f8358 100644 --- a/expfmt/text_parse.go +++ b/expfmt/text_parse.go @@ -78,6 +78,9 @@ type TextParser struct { // These indicate if the metric name from the current line being parsed is inside // braces and if that metric name was found respectively. currentMetricIsInsideBraces, currentMetricInsideBracesIsPresent bool + // scheme sets the desired ValidationScheme for names. Defaults to the invalid + // UnsetValidation. + scheme model.ValidationScheme } // TextToMetricFamilies reads 'in' as the simple and flat text-based exchange @@ -126,6 +129,7 @@ func (p *TextParser) TextToMetricFamilies(in io.Reader) (map[string]*dto.MetricF func (p *TextParser) reset(in io.Reader) { p.metricFamiliesByName = map[string]*dto.MetricFamily{} + p.currentLabelPairs = nil if p.buf == nil { p.buf = bufio.NewReader(in) } else { @@ -216,6 +220,9 @@ func (p *TextParser) startComment() stateFn { return nil } p.setOrCreateCurrentMF() + if p.err != nil { + return nil + } if p.skipBlankTab(); p.err != nil { return nil // Unexpected end of input. } @@ -244,6 +251,9 @@ func (p *TextParser) readingMetricName() stateFn { return nil } p.setOrCreateCurrentMF() + if p.err != nil { + return nil + } // Now is the time to fix the type if it hasn't happened yet. if p.currentMF.Type == nil { p.currentMF.Type = dto.MetricType_UNTYPED.Enum() @@ -311,6 +321,9 @@ func (p *TextParser) startLabelName() stateFn { switch p.currentByte { case ',': p.setOrCreateCurrentMF() + if p.err != nil { + return nil + } if p.currentMF.Type == nil { p.currentMF.Type = dto.MetricType_UNTYPED.Enum() } @@ -319,6 +332,10 @@ func (p *TextParser) startLabelName() stateFn { return p.startLabelName case '}': p.setOrCreateCurrentMF() + if p.err != nil { + p.currentLabelPairs = nil + return nil + } if p.currentMF.Type == nil { p.currentMF.Type = dto.MetricType_UNTYPED.Enum() } @@ -341,6 +358,12 @@ func (p *TextParser) startLabelName() stateFn { p.currentLabelPair = &dto.LabelPair{Name: proto.String(p.currentToken.String())} if p.currentLabelPair.GetName() == string(model.MetricNameLabel) { p.parseError(fmt.Sprintf("label name %q is reserved", model.MetricNameLabel)) + p.currentLabelPairs = nil + return nil + } + if !p.scheme.IsValidLabelName(p.currentLabelPair.GetName()) { + p.parseError(fmt.Sprintf("invalid label name %q", p.currentLabelPair.GetName())) + p.currentLabelPairs = nil return nil } // Special summary/histogram treatment. Don't add 'quantile' and 'le' @@ -805,6 +828,10 @@ func (p *TextParser) setOrCreateCurrentMF() { p.currentIsHistogramCount = false p.currentIsHistogramSum = false name := p.currentToken.String() + if !p.scheme.IsValidMetricName(name) { + p.parseError(fmt.Sprintf("invalid metric name %q", name)) + return + } if p.currentMF = p.metricFamiliesByName[name]; p.currentMF != nil { return } diff --git a/expfmt/text_parse_test.go b/expfmt/text_parse_test.go index fac60ba6f..49fcfe778 100644 --- a/expfmt/text_parse_test.go +++ b/expfmt/text_parse_test.go @@ -21,6 +21,8 @@ import ( dto "github.com/prometheus/client_model/go" "google.golang.org/protobuf/proto" + + "github.com/prometheus/common/model" ) func testTextParse(t testing.TB) { @@ -191,10 +193,10 @@ my_summary{n1="val3", quantile="0.2"} 4711 my_summary{n1="val1",n2="val2",quantile="-12.34",} NaN # some # funny comments -# HELP # HELP +# HELP +# HELP my_summary # HELP my_summary -# HELP my_summary `, out: []*dto.MetricFamily{ { @@ -682,20 +684,22 @@ func BenchmarkTextParse(b *testing.B) { func testTextParseError(t testing.TB) { scenarios := []struct { - in string - err string + in string + errUTF8 string + // if errLegacy is blank, it is assumed to be the same as errUTF8 + errLegacy string }{ // 0: No new-line at end of input. { in: ` bla 3.14 blubber 42`, - err: "text format parsing error in line 3: unexpected end of input stream", + errUTF8: "text format parsing error in line 3: unexpected end of input stream", }, // 1: Invalid escape sequence in label value. { - in: `metric{label="\t"} 3.14`, - err: "text format parsing error in line 1: invalid escape sequence", + in: `metric{label="\t"} 3.14`, + errUTF8: "text format parsing error in line 1: invalid escape sequence", }, // 2: Newline in label value. { @@ -703,27 +707,27 @@ blubber 42`, metric{label="new line"} 3.14 `, - err: `text format parsing error in line 2: label value "new" contains unescaped new-line`, + errUTF8: `text format parsing error in line 2: label value "new" contains unescaped new-line`, }, // 3: { - in: `metric{@="bla"} 3.14`, - err: "text format parsing error in line 1: invalid label name for metric", + in: `metric{@="bla"} 3.14`, + errUTF8: "text format parsing error in line 1: invalid label name for metric", }, // 4: { - in: `metric{__name__="bla"} 3.14`, - err: `text format parsing error in line 1: label name "__name__" is reserved`, + in: `metric{__name__="bla"} 3.14`, + errUTF8: `text format parsing error in line 1: label name "__name__" is reserved`, }, // 5: { - in: `metric{label+="bla"} 3.14`, - err: "text format parsing error in line 1: expected '=' after label name", + in: `metric{label+="bla"} 3.14`, + errUTF8: "text format parsing error in line 1: expected '=' after label name", }, // 6: { - in: `metric{label=bla} 3.14`, - err: "text format parsing error in line 1: expected '\"' at start of label value", + in: `metric{label=bla} 3.14`, + errUTF8: "text format parsing error in line 1: expected '\"' at start of label value", }, // 7: { @@ -731,30 +735,30 @@ line"} 3.14 # TYPE metric summary metric{quantile="bla"} 3.14 `, - err: "text format parsing error in line 3: expected float as value for 'quantile' label", + errUTF8: "text format parsing error in line 3: expected float as value for 'quantile' label", }, // 8: { - in: `metric{label="bla"+} 3.14`, - err: "text format parsing error in line 1: unexpected end of label value", + in: `metric{label="bla"+} 3.14`, + errUTF8: "text format parsing error in line 1: unexpected end of label value", }, // 9: { in: `metric{label="bla"} 3.14 2.72 `, - err: "text format parsing error in line 1: expected integer as timestamp", + errUTF8: "text format parsing error in line 1: expected integer as timestamp", }, // 10: { in: `metric{label="bla"} 3.14 2 3 `, - err: "text format parsing error in line 1: spurious string after timestamp", + errUTF8: "text format parsing error in line 1: spurious string after timestamp", }, // 11: { in: `metric{label="bla"} blubb `, - err: "text format parsing error in line 1: expected float as value", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 12: { @@ -762,7 +766,7 @@ metric{quantile="bla"} 3.14 # HELP metric one # HELP metric two `, - err: "text format parsing error in line 3: second HELP line for metric name", + errUTF8: "text format parsing error in line 3: second HELP line for metric name", }, // 13: { @@ -770,7 +774,7 @@ metric{quantile="bla"} 3.14 # TYPE metric counter # TYPE metric untyped `, - err: `text format parsing error in line 3: second TYPE line for metric name "metric", or TYPE reported after samples`, + errUTF8: `text format parsing error in line 3: second TYPE line for metric name "metric", or TYPE reported after samples`, }, // 14: { @@ -778,31 +782,31 @@ metric{quantile="bla"} 3.14 metric 4.12 # TYPE metric counter `, - err: `text format parsing error in line 3: second TYPE line for metric name "metric", or TYPE reported after samples`, + errUTF8: `text format parsing error in line 3: second TYPE line for metric name "metric", or TYPE reported after samples`, }, // 14: { in: ` # TYPE metric bla `, - err: "text format parsing error in line 2: unknown metric type", + errUTF8: "text format parsing error in line 2: unknown metric type", }, // 15: { in: ` # TYPE met-ric `, - err: "text format parsing error in line 2: invalid metric name in comment", + errUTF8: "text format parsing error in line 2: invalid metric name in comment", }, // 16: { - in: `@invalidmetric{label="bla"} 3.14 2`, - err: "text format parsing error in line 1: invalid metric name", + in: `@invalidmetric{label="bla"} 3.14 2`, + errUTF8: "text format parsing error in line 1: invalid metric name", }, // 17: { - in: `{label="bla"} 3.14 2`, - err: "text format parsing error in line 1: invalid metric name", + in: `{label="bla"} 3.14 2`, + errUTF8: "text format parsing error in line 1: invalid metric name", }, // 18: { @@ -810,67 +814,67 @@ metric 4.12 # TYPE metric histogram metric_bucket{le="bla"} 3.14 `, - err: "text format parsing error in line 3: expected float as value for 'le' label", + errUTF8: "text format parsing error in line 3: expected float as value for 'le' label", }, // 19: Invalid UTF-8 in label value. { - in: "metric{l=\"\xbd\"} 3.14\n", - err: "text format parsing error in line 1: invalid label value \"\\xbd\"", + in: "metric{l=\"\xbd\"} 3.14\n", + errUTF8: "text format parsing error in line 1: invalid label value \"\\xbd\"", }, // 20: Go 1.13 sometimes allows underscores in numbers. { - in: "foo 1_2\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 1_2\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 21: Go 1.13 supports hex floating point. { - in: "foo 0x1p-3\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0x1p-3\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 22: Check for various other literals variants, just in case. { - in: "foo 0x1P-3\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0x1P-3\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 23: { - in: "foo 0B1\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0B1\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 24: { - in: "foo 0O1\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0O1\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 25: { - in: "foo 0X1\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0X1\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 26: { - in: "foo 0x1\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0x1\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 27: { - in: "foo 0b1\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0b1\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 28: { - in: "foo 0o1\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0o1\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 29: { - in: "foo 0x1\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0x1\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 30: { - in: "foo 0x1\n", - err: "text format parsing error in line 1: expected float as value", + in: "foo 0x1\n", + errUTF8: "text format parsing error in line 1: expected float as value", }, // 31: Check histogram label. { @@ -878,7 +882,7 @@ metric_bucket{le="bla"} 3.14 # TYPE metric histogram metric_bucket{le="0x1p-3"} 3.14 `, - err: "text format parsing error in line 3: expected float as value for 'le' label", + errUTF8: "text format parsing error in line 3: expected float as value for 'le' label", }, // 32: Check quantile label. { @@ -886,27 +890,28 @@ metric_bucket{le="0x1p-3"} 3.14 # TYPE metric summary metric{quantile="0x1p-3"} 3.14 `, - err: "text format parsing error in line 3: expected float as value for 'quantile' label", + errUTF8: "text format parsing error in line 3: expected float as value for 'quantile' label", }, // 33: Check duplicate label. { - in: `metric{label="bla",label="bla"} 3.14`, - err: "text format parsing error in line 1: duplicate label names for metric", + in: `metric{label="bla",label="bla"} 3.14`, + errUTF8: "text format parsing error in line 1: duplicate label names for metric", }, // 34: Multiple quoted metric names. { - in: `{"one.name","another.name"} 3.14`, - err: "text format parsing error in line 1: multiple metric names", + in: `{"one.name","another.name"} 3.14`, + errUTF8: "text format parsing error in line 1: multiple metric names", + errLegacy: `text format parsing error in line 1: invalid metric name "one.name"`, }, // 35: Invalid escape sequence in quoted metric name. { - in: `{"a\xc5z",label="bla"} 3.14`, - err: "text format parsing error in line 1: invalid escape sequence", + in: `{"a\xc5z",label="bla"} 3.14`, + errUTF8: "text format parsing error in line 1: invalid escape sequence", }, // 36: Unexpected end of quoted metric name. { - in: `{"metric.name".label="bla"} 3.14`, - err: "text format parsing error in line 1: unexpected end of metric name", + in: `{"metric.name".label="bla"} 3.14`, + errUTF8: "text format parsing error in line 1: unexpected end of metric name", }, // 37: Invalid escape sequence in quoted metric name. { @@ -914,7 +919,7 @@ metric{quantile="0x1p-3"} 3.14 # TYPE "metric.name\t" counter {"metric.name\t",label="bla"} 3.14 `, - err: "text format parsing error in line 2: invalid escape sequence", + errUTF8: "text format parsing error in line 2: invalid escape sequence", }, // 38: Newline in quoted metric name. { @@ -924,7 +929,7 @@ name" counter {"metric name",label="bla"} 3.14 `, - err: `text format parsing error in line 2: metric name "metric" contains unescaped new-line`, + errUTF8: `text format parsing error in line 2: metric name "metric" contains unescaped new-line`, }, // 39: Newline in quoted label name. { @@ -932,21 +937,55 @@ name",label="bla"} 3.14 {"metric.name","new line"="bla"} 3.14 `, - err: `text format parsing error in line 2: label name "new" contains unescaped new-line`, + errUTF8: `text format parsing error in line 2: label name "new" contains unescaped new-line`, + errLegacy: `text format parsing error in line 2: invalid metric name "metric.name"`, + }, + // 40: dotted name fails legacy validation. + { + in: `{"metric.name",foo="bla"} 3.14 +`, + errUTF8: ``, + errLegacy: `text format parsing error in line 1: invalid metric name "metric.name"`, + }, + { + in: `metric_name{"foo"="bar", "dotted.label"="bla"} 3.14 +`, + errUTF8: ``, + errLegacy: `text format parsing error in line 1: invalid label name "dotted.label"`, }, } for i, scenario := range scenarios { + parser.scheme = model.UTF8Validation _, err := parser.TextToMetricFamilies(strings.NewReader(scenario.in)) if err == nil { - t.Errorf("%d. expected error, got nil", i) - continue - } - if expected, got := scenario.err, err.Error(); strings.Index(got, expected) != 0 { + if scenario.errUTF8 != "" { + t.Errorf("%d. expected error, got nil", i) + } + } else if expected, got := scenario.errUTF8, err.Error(); strings.Index(got, expected) != 0 { t.Errorf( "%d. expected error starting with %q, got %q", i, expected, got, ) } + + parser.scheme = model.LegacyValidation + _, err = parser.TextToMetricFamilies(strings.NewReader(scenario.in)) + if err == nil { + if scenario.errLegacy != "" { + t.Errorf("%d. expected error, got nil", i) + } + } else { + expected := scenario.errUTF8 + if scenario.errLegacy != "" { + expected = scenario.errLegacy + } + if got := err.Error(); strings.Index(got, expected) != 0 { + t.Errorf( + "%d. expected error starting with %q, got %q", + i, expected, got, + ) + } + } } }