From cca736efbd8f6a17daadb452396f663e7b3a3c13 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 21 Apr 2026 17:54:59 -0400 Subject: [PATCH 01/14] implement native rules for the predefined protovalidate rules --- README.md | 64 ++ base.go | 31 + builder.go | 88 ++ cel/library.go | 66 +- cel/library_test.go | 12 +- dynamic_end_to_end_test.go | 587 ++++++++++++++ .../gen/tests/example/v1/native_test.pb.go | 759 ++++++++++++++++++ .../example/v1/native_test_protoopaque.pb.go | 759 ++++++++++++++++++ native_bool.go | 67 ++ native_bool_test.go | 93 +++ native_bytes.go | 391 +++++++++ native_bytes_test.go | 297 +++++++ native_enum.go | 111 +++ native_enum_test.go | 152 ++++ native_map.go | 101 +++ native_map_test.go | 204 +++++ native_numeric.go | 576 +++++++++++++ native_numeric_test.go | 544 +++++++++++++ native_repeated.go | 183 +++++ native_repeated_test.go | 197 +++++ native_selection_test.go | 290 +++++++ native_string.go | 703 ++++++++++++++++ native_string_test.go | 487 +++++++++++ proto/tests/example/v1/native_test.proto | 170 ++++ validator_bench_test.go | 90 ++- 25 files changed, 6975 insertions(+), 47 deletions(-) create mode 100644 dynamic_end_to_end_test.go create mode 100644 internal/gen/tests/example/v1/native_test.pb.go create mode 100644 internal/gen/tests/example/v1/native_test_protoopaque.pb.go create mode 100644 native_bool.go create mode 100644 native_bool_test.go create mode 100644 native_bytes.go create mode 100644 native_bytes_test.go create mode 100644 native_enum.go create mode 100644 native_enum_test.go create mode 100644 native_map.go create mode 100644 native_map_test.go create mode 100644 native_numeric.go create mode 100644 native_numeric_test.go create mode 100644 native_repeated.go create mode 100644 native_repeated_test.go create mode 100644 native_selection_test.go create mode 100644 native_string.go create mode 100644 native_string_test.go create mode 100644 proto/tests/example/v1/native_test.proto diff --git a/README.md b/README.md index 47c6bcda..315cfff0 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,70 @@ Highlights for Go developers include: API documentation for Go is available on [pkg.go.dev][pkg-go]. +### Experimental native support release +This release provides experimental native support for standard validation rule processing. It is disabled by default +and enabled by setting the environment variable `PV_NATIVE_RULES` to `true` at runtime. + +Performance improvements on the included benchmarks: + +``` +$ benchstat 2026-04-21:17:17:52.bench.txt 2026-04-21:17:20:31.bench.txt +goos: darwin +goarch: arm64 +pkg: buf.build/go/protovalidate +cpu: Apple M1 Max + │ 2026-04-21:17:17:52.bench.txt │ 2026-04-21:17:20:31.bench.txt │ + │ sec/op │ sec/op vs base │ +Scalar-10 167.35n ± 1% 70.97n ± 0% -57.59% (p=0.000 n=10) +Repeated/Scalar-10 285.60n ± 1% 99.10n ± 1% -65.30% (p=0.000 n=10) +Repeated/Message-10 691.3n ± 1% 296.7n ± 0% -57.09% (p=0.000 n=10) +Repeated/Unique/Scalar-10 1249.0n ± 0% 556.8n ± 0% -55.42% (p=0.000 n=10) +Repeated/Unique/Bytes-10 2514.5n ± 0% 967.0n ± 1% -61.54% (p=0.000 n=10) +Map-10 289.9n ± 1% 103.2n ± 1% -64.39% (p=0.000 n=10) +ComplexSchema-10 40.93µ ± 0% 14.27µ ± 1% -65.14% (p=0.000 n=10) +Int32GT-10 2748.0n ± 0% 845.6n ± 0% -69.23% (p=0.000 n=10) +TestByteMatching-10 1348.0n ± 0% 206.2n ± 0% -84.70% (p=0.000 n=10) +Compile-10 8.262m ± 0% 1.357m ± 1% -83.58% (p=0.000 n=10) +CompileInt32GT-10 5.887m ± 1% 1.241m ± 0% -78.91% (p=0.000 n=10) +geomean 5.738µ 1.755µ -69.42% + + │ 2026-04-21:17:17:52.bench.txt │ 2026-04-21:17:20:31.bench.txt │ + │ B/op │ B/op vs base │ +Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ +Repeated/Scalar-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) +Repeated/Message-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) +Repeated/Unique/Scalar-10 536.0 ± 0% 272.0 ± 0% -49.25% (p=0.000 n=10) +Repeated/Unique/Bytes-10 1784.0 ± 0% 832.0 ± 0% -53.36% (p=0.000 n=10) +Map-10 128.00 ± 0% 64.00 ± 0% -50.00% (p=0.000 n=10) +ComplexSchema-10 10.552Ki ± 0% 4.383Ki ± 0% -58.46% (p=0.000 n=10) +Int32GT-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ +TestByteMatching-10 408.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) +Compile-10 6.811Mi ± 0% 1.671Mi ± 0% -75.47% (p=0.000 n=10) +CompileInt32GT-10 5.199Mi ± 0% 1.606Mi ± 0% -69.11% (p=0.000 n=10) +geomean ² ? ² ³ +¹ all samples are equal +² summaries must be >0 to compute geomean +³ ratios must be >0 to compute geomean + + │ 2026-04-21:17:17:52.bench.txt │ 2026-04-21:17:20:31.bench.txt │ + │ allocs/op │ allocs/op vs base │ +Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ +Repeated/Scalar-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) +Repeated/Message-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) +Repeated/Unique/Scalar-10 34.00 ± 0% 14.00 ± 0% -58.82% (p=0.000 n=10) +Repeated/Unique/Bytes-10 73.00 ± 0% 24.00 ± 0% -67.12% (p=0.000 n=10) +Map-10 2.000 ± 0% 1.000 ± 0% -50.00% (p=0.000 n=10) +ComplexSchema-10 419.0 ± 0% 131.0 ± 0% -68.74% (p=0.000 n=10) +Int32GT-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ +TestByteMatching-10 17.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) +Compile-10 96.14k ± 0% 18.35k ± 0% -80.91% (p=0.000 n=10) +CompileInt32GT-10 64.56k ± 0% 17.53k ± 0% -72.84% (p=0.000 n=10) +geomean ² ? ² ³ +¹ all samples are equal +² summaries must be >0 to compute geomean +³ ratios must be >0 to compute geomean +``` + ## Additional languages and repositories Protovalidate isn't just for Go! You might be interested in sibling repositories for other languages: diff --git a/base.go b/base.go index b31cdec3..c1b225ef 100644 --- a/base.go +++ b/base.go @@ -18,6 +18,7 @@ import ( "slices" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -60,6 +61,36 @@ func (b *base) rulePath(suffix *validate.FieldPath) *validate.FieldPath { return prefixRulePath(b.RulePrefix, suffix) } +// newViolation constructs a ValidationError with a single violation. +// ruleDesc is the top-level rule descriptor (e.g., FieldRules.int32), +// desc is the specific constraint descriptor (e.g., Int32Rules.gt). +func (b *base) newViolation( + ruleDesc protoreflect.FieldDescriptor, + desc protoreflect.FieldDescriptor, + ruleID string, + message string, + fieldValue protoreflect.Value, + ruleValue protoreflect.Value, +) error { + return &ValidationError{Violations: []*Violation{{ + Proto: validate.Violation_builder{ + Field: b.fieldPath(), + Rule: b.rulePath(validate.FieldPath_builder{ + Elements: []*validate.FieldPathElement{ + fieldPathElement(ruleDesc), + fieldPathElement(desc), + }, + }.Build()), + RuleId: proto.String(ruleID), + Message: proto.String(message), + }.Build(), + FieldValue: fieldValue, + FieldDescriptor: b.Descriptor, + RuleValue: ruleValue, + RuleDescriptor: desc, + }}} +} + func prefixRulePath(prefix *validate.FieldPath, suffix *validate.FieldPath) *validate.FieldPath { if len(prefix.GetElements()) > 0 { return validate.FieldPath_builder{ diff --git a/builder.go b/builder.go index 36cc9a97..8bf36292 100644 --- a/builder.go +++ b/builder.go @@ -18,7 +18,9 @@ import ( "cmp" "fmt" "maps" + "os" "slices" + "strings" "sync" "sync/atomic" @@ -464,6 +466,33 @@ func (bldr *builder) processStandardRules( } } + // put behind a feature flag to allow for testing. + // it's easier to follow like this, don't break it up + //nolint:nestif + if f, _ := os.LookupEnv("PV_NATIVE_RULES"); strings.EqualFold(f, "true") { + // Try native Go evaluators for repeated list-level rules (min_items, max_items, unique). + if fdesc.IsList() && valEval.NestedRule == nil { + if native := tryNativeRepeatedRules(newBase(valEval), rules.GetRepeated()); native != nil { + valEval.Append(native) + return nil + } + } + // Try native Go evaluators for map-level rules (min_pairs, max_pairs). + if fdesc.IsMap() && valEval.NestedRule == nil { + if native := tryNativeMapRules(newBase(valEval), rules.GetMap()); native != nil { + valEval.Append(native) + return nil + } + } + // Try native Go evaluators for known simple rules before falling back to CEL. + if !fdesc.IsMap() && !fdesc.IsList() { + if native := bldr.tryNativeRules(fdesc, rules, valEval); native != nil { + valEval.Append(native) + return nil + } + } + } + stdRules, err := bldr.rules.Build( bldr.env, fdesc, @@ -482,6 +511,65 @@ func (bldr *builder) processStandardRules( return nil } +func (bldr *builder) tryNativeRules( + fdesc protoreflect.FieldDescriptor, + rules *validate.FieldRules, + valEval *value, +) evaluator { + if rules == nil { + return nil + } + // When processWrapperRules unwraps a wrapper type (e.g., Int32Value), + // it calls buildValue with the inner scalar field descriptor but + // attaches the resulting evaluators to the outer value. At runtime, + // the evaluator receives the wrapper message, not the inner scalar, + // so native evaluators that call val.Int() would panic. The outer + // value's Descriptor still points to the wrapper message field, so + // we use its Kind to detect this case and fall back to CEL. + if valEval.Descriptor != nil && + (valEval.Descriptor.Kind() == protoreflect.MessageKind || + valEval.Descriptor.Kind() == protoreflect.GroupKind) { + return nil + } + base := newBase(valEval) + switch fdesc.Kind() { + case protoreflect.Int32Kind: + return tryBuildNativeInt32Rules(base, rules.GetInt32()) + case protoreflect.Sint32Kind: + return tryBuildNativeSint32Rules(base, rules.GetSint32()) + case protoreflect.Sfixed32Kind: + return tryBuildNativeSfixed32Rules(base, rules.GetSfixed32()) + case protoreflect.Int64Kind: + return tryBuildNativeInt64Rules(base, rules.GetInt64()) + case protoreflect.Sint64Kind: + return tryBuildNativeSint64Rules(base, rules.GetSint64()) + case protoreflect.Sfixed64Kind: + return tryBuildNativeSfixed64Rules(base, rules.GetSfixed64()) + case protoreflect.Uint32Kind: + return tryBuildNativeUint32Rules(base, rules.GetUint32()) + case protoreflect.Fixed32Kind: + return tryBuildNativeFixed32Rules(base, rules.GetFixed32()) + case protoreflect.Uint64Kind: + return tryBuildNativeUint64Rules(base, rules.GetUint64()) + case protoreflect.Fixed64Kind: + return tryBuildNativeFixed64Rules(base, rules.GetFixed64()) + case protoreflect.FloatKind: + return tryBuildNativeFloatRules(base, rules.GetFloat()) + case protoreflect.DoubleKind: + return tryBuildNativeDoubleRules(base, rules.GetDouble()) + case protoreflect.StringKind: + return tryBuildNativeStringRules(base, rules.GetString()) + case protoreflect.BoolKind: + return tryBuildNativeBoolRules(base, rules.GetBool()) + case protoreflect.EnumKind: + return tryBuildNativeEnumRules(base, rules.GetEnum()) + case protoreflect.BytesKind: + return tryBuildNativeBytesRules(base, rules.GetBytes()) + default: + return nil + } +} + func (bldr *builder) processAnyRules( fdesc protoreflect.FieldDescriptor, fieldRules *validate.FieldRules, diff --git a/cel/library.go b/cel/library.go index e29696e3..53899116 100644 --- a/cel/library.go +++ b/cel/library.go @@ -161,7 +161,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(isHostname(host)) + return types.Bool(IsHostname(host)) }), ), ), @@ -175,7 +175,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(isEmail(addr)) + return types.Bool(IsEmail(addr)) }), ), ), @@ -189,7 +189,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(isIP(addr, 0)) + return types.Bool(IsIP(addr, 0)) }), ), cel.MemberOverload( @@ -202,7 +202,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !aok || !vok { return types.Bool(false) } - return types.Bool(isIP(addr, vers)) + return types.Bool(IsIP(addr, vers)) })), ), cel.Function("isIpPrefix", @@ -215,7 +215,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(isIPPrefix(prefix, 0, false)) + return types.Bool(IsIPPrefix(prefix, 0, false)) })), cel.MemberOverload( "string_int_is_ip_prefix_bool", @@ -227,7 +227,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !pok || !vok { return types.Bool(false) } - return types.Bool(isIPPrefix(prefix, vers, false)) + return types.Bool(IsIPPrefix(prefix, vers, false)) })), cel.MemberOverload( "string_bool_is_ip_prefix_bool", @@ -239,7 +239,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !pok || !sok { return types.Bool(false) } - return types.Bool(isIPPrefix(prefix, 0, strict)) + return types.Bool(IsIPPrefix(prefix, 0, strict)) })), cel.MemberOverload( "string_int_bool_is_ip_prefix_bool", @@ -252,7 +252,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !pok || !vok || !sok { return types.Bool(false) } - return types.Bool(isIPPrefix(prefix, vers, strict)) + return types.Bool(IsIPPrefix(prefix, vers, strict)) })), ), cel.Function("isUri", @@ -265,7 +265,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(isURI(s)) + return types.Bool(IsURI(s)) }), ), ), @@ -279,7 +279,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(isURIRef(s)) + return types.Bool(IsURIRef(s)) }), ), ), @@ -379,7 +379,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !vok || !pok { return types.Bool(false) } - return types.Bool(isHostAndPort(val, portReq)) + return types.Bool(IsHostAndPort(val, portReq)) }), ), ), @@ -464,35 +464,35 @@ func (l *library) uniqueBytes(list traits.Lister) ref.Val { return types.Bool(true) } -// isEmail reports whether val is an email address, for example "foo@example.com". +// IsEmail reports whether val is an email address, for example "foo@example.com". // // Conforms to the definition for a valid email address from the HTML standard. // Note that this standard willfully deviates from RFC 5322, which allows many // unexpected forms of email addresses and will easily match a typographical // error. -func isEmail(val string) bool { +func IsEmail(val string) bool { return emailRegex.MatchString(val) } -// isURI reports whether val is a URI, for example "https://example.com/foo/bar?baz=quux#frag". +// IsURI reports whether val is a URI, for example "https://example.com/foo/bar?baz=quux#frag". // // URI is defined in the internet standard RFC 3986. // Zone Identifiers in IPv6 address literals are supported (RFC 6874). -func isURI(val string) bool { +func IsURI(val string) bool { uri := &uri{ str: val, } return uri.uri() } -// isURIRef reports whether val is a URI Reference - a URI such as +// IsURIRef reports whether val is a URI Reference - a URI such as // "https://example.com/foo/bar?baz=quux#frag", or a Relative Reference such as // "./foo/bar?query". // // URI, URI Reference, and Relative Reference are defined in the internet // standard RFC 3986. Zone Identifiers in IPv6 address literals are supported // (RFC 6874). -func isURIRef(val string) bool { +func IsURIRef(val string) bool { uri := &uri{ str: val, } @@ -950,7 +950,7 @@ func newIpv6(str string) *ipv6 { } } -// isIP returns true if the string is an IPv4 or IPv6 address, optionally limited to +// IsIP returns true if the string is an IPv4 or IPv6 address, optionally limited to // a specific version. // // Version 0 means either 4 or 6. Passing a version other than 0, 4, or 6 always @@ -962,7 +962,7 @@ func newIpv6(str string) *ipv6 { // // Both formats are well-defined in the internet standard RFC 3986. Zone // identifiers for IPv6 addresses (for example "fe80::a%en1") are supported. -func isIP(str string, version int64) bool { +func IsIP(str string, version int64) bool { if version == 6 { return newIpv6(str).address() } @@ -975,7 +975,7 @@ func isIP(str string, version int64) bool { return false } -// isIPPrefix returns true if the string is a valid IP with prefix length, optionally +// IsIPPrefix returns true if the string is a valid IP with prefix length, optionally // limited to a specific version (v4 or v6), and optionally requiring the host // portion to be all zeros. // @@ -991,7 +991,7 @@ func isIP(str string, version int64) bool { // // The same principle applies to IPv4 addresses. "192.168.1.0/24" designates // the first 24 bits of the 32-bit IPv4 as the network prefix. -func isIPPrefix( +func IsIPPrefix( str string, version int64, strict bool, @@ -1005,12 +1005,12 @@ func isIPPrefix( return ip.addressPrefix() && (!strict || ip.isPrefixOnly()) } if version == 0 { - return isIPPrefix(str, 6, strict) || isIPPrefix(str, 4, strict) + return IsIPPrefix(str, 6, strict) || IsIPPrefix(str, 4, strict) } return false } -// isHostname returns true if the string is a valid hostname, for example "foo.example.com". +// IsHostname returns true if the string is a valid hostname, for example "foo.example.com". // // A valid hostname follows the rules below: // - The name consists of one or more labels, separated by a dot ("."). @@ -1019,7 +1019,7 @@ func isIPPrefix( // - The right-most label must not be digits only. // - The name can have a trailing dot, for example "foo.example.com.". // - The name can be 253 characters at most, excluding the optional trailing dot. -func isHostname(val string) bool { +func IsHostname(val string) bool { if len(val) > 253 { return false } @@ -1054,7 +1054,7 @@ func isHostname(val string) bool { return !allDigits } -// isHostAndPort returns true if the string is a valid host/port pair, for example +// IsHostAndPort returns true if the string is a valid host/port pair, for example // "example.com:8080". // // If the argument portRequired is true, the port is required. If the argument @@ -1067,7 +1067,7 @@ func isHostname(val string) bool { // // The port is separated by a colon. It must be non-empty, with a decimal number // in the range of 0-65535, inclusive. -func isHostAndPort(str string, portRequired bool) bool { +func IsHostAndPort(str string, portRequired bool) bool { if len(str) == 0 { return false } @@ -1076,22 +1076,22 @@ func isHostAndPort(str string, portRequired bool) bool { end := strings.LastIndex(str, "]") switch end + 1 { case len(str): // no port - return !portRequired && isIP(str[1:end], 6) + return !portRequired && IsIP(str[1:end], 6) case splitIdx: // port - return isIP(str[1:end], 6) && isPort(str[splitIdx+1:]) + return IsIP(str[1:end], 6) && isPort(str[splitIdx+1:]) default: // malformed return false } } if splitIdx < 0 { - return !portRequired && (isHostname(str) || isIP(str, 4)) + return !portRequired && (IsHostname(str) || IsIP(str, 4)) } host := str[0:splitIdx] port := str[splitIdx+1:] - return (isHostname(host) || isIP(host, 4)) && isPort(port) + return (IsHostname(host) || IsIP(host, 4)) && isPort(port) } -// isPort returns true if the string is a valid port for isHostAndPort. +// isPort returns true if the string is a valid port for IsHostAndPort. func isPort(str string) bool { if len(str) == 0 { return false @@ -1382,13 +1382,13 @@ func (u *uri) ipLiteral() bool { // ipv6Address parses the rule "IPv6address". // -// Relies on the implementation of isIP. +// Relies on the implementation of IsIP. func (u *uri) ipv6Address() bool { start := u.index for u.hexdig() || u.take(':') { // Consume '*( HEXDIG / ":" )' } - if isIP(u.str[start:u.index], 6) { + if IsIP(u.str[start:u.index], 6) { return true } u.index = start diff --git a/cel/library_test.go b/cel/library_test.go index 597cf543..9f623281 100644 --- a/cel/library_test.go +++ b/cel/library_test.go @@ -260,18 +260,18 @@ func buildTestProgram(t *testing.T, env *cel.Env, expr string) cel.Program { func TestIsUri(t *testing.T) { t.Parallel() - require.True(t, isURI("A://")) + require.True(t, IsURI("A://")) } func TestIsHostname(t *testing.T) { t.Parallel() - require.True(t, isHostname("foo.example.com")) - require.True(t, isHostname("A.ISI.EDU")) - require.False(t, isHostname("İ")) + require.True(t, IsHostname("foo.example.com")) + require.True(t, IsHostname("A.ISI.EDU")) + require.False(t, IsHostname("İ")) } func TestIsHostAndPort(t *testing.T) { t.Parallel() - require.False(t, isHostAndPort("example.com:080", false)) - require.False(t, isHostAndPort("example.com:00", false)) + require.False(t, IsHostAndPort("example.com:080", false)) + require.False(t, IsHostAndPort("example.com:00", false)) } diff --git a/dynamic_end_to_end_test.go b/dynamic_end_to_end_test.go new file mode 100644 index 00000000..70b51919 --- /dev/null +++ b/dynamic_end_to_end_test.go @@ -0,0 +1,587 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "math" + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +func TestDynamicRulesEndToEnd(t *testing.T) { + data := []struct { + name string + typ *descriptorpb.FieldDescriptorProto_Type + rule *validate.FieldRules + info dynamicMessageTesterInfo + }{ + { + name: "int32_gt", + typ: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + rule: validate.FieldRules_builder{ + Int32: validate.Int32Rules_builder{Gt: proto.Int32(0)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfInt32(1), + badValue: protoreflect.ValueOfInt32(0), + failedRuleID: "int32.gt", + failedRuleMessage: "must be greater than 0", + }, + }, + { + name: "uint64_gte", + typ: descriptorpb.FieldDescriptorProto_TYPE_UINT64.Enum(), + rule: validate.FieldRules_builder{ + Uint64: validate.UInt64Rules_builder{Gte: proto.Uint64(10)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfUint64(10), + badValue: protoreflect.ValueOfUint64(9), + failedRuleID: "uint64.gte", + failedRuleMessage: "must be greater than or equal to 10", + }, + }, + { + name: "double_lt", + typ: descriptorpb.FieldDescriptorProto_TYPE_DOUBLE.Enum(), + rule: validate.FieldRules_builder{ + Double: validate.DoubleRules_builder{Lt: proto.Float64(100)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfFloat64(50), + badValue: protoreflect.ValueOfFloat64(100), + failedRuleID: "double.lt", + failedRuleMessage: "must be less than 100", + }, + }, + { + name: "double_finite", + typ: descriptorpb.FieldDescriptorProto_TYPE_DOUBLE.Enum(), + rule: validate.FieldRules_builder{ + Double: validate.DoubleRules_builder{Finite: proto.Bool(true)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfFloat64(50), + badValue: protoreflect.ValueOfFloat64(math.Inf(1)), + failedRuleID: "double.finite", + failedRuleMessage: "must be finite", + }, + }, + { + name: "min_len", + typ: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + rule: validate.FieldRules_builder{ + String: validate.StringRules_builder{MinLen: proto.Uint64(3)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfString("abc"), + badValue: protoreflect.ValueOfString("ab"), + failedRuleID: "string.min_len", + failedRuleMessage: "must be at least 3 characters", + }, + }, + { + name: "prefix", + typ: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + rule: validate.FieldRules_builder{ + String: validate.StringRules_builder{Prefix: proto.String("hello")}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfString("hello world"), + badValue: protoreflect.ValueOfString("world"), + failedRuleID: "string.prefix", + failedRuleMessage: "does not have prefix `hello`", + }, + }, + { + name: "bool_const", + typ: descriptorpb.FieldDescriptorProto_TYPE_BOOL.Enum(), + rule: validate.FieldRules_builder{ + Bool: validate.BoolRules_builder{Const: proto.Bool(true)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfBool(true), + badValue: protoreflect.ValueOfBool(false), + failedRuleID: "bool.const", + failedRuleMessage: "must equal true", + }, + }, + } + for _, d := range data { + t.Run(d.name, func(t *testing.T) { + // we are setting an environment variable to enable native rules, so we can't use parallel tests + msgType := newDynamicMessageType(t, "test.native", "TestMessage", &descriptorpb.FieldDescriptorProto{ + Name: proto.String("value"), + Number: proto.Int32(1), + Type: d.typ, + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Options: fieldOpts(d.rule), + }) + d.info.msgType = msgType + // first with CEL rules + t.Setenv("PV_NATIVE_RULES", "false") + dynamicMessageTester(t, d.info, "value") + // now with native rules to validate they produce identical results + t.Setenv("PV_NATIVE_RULES", "true") + dynamicMessageTester(t, d.info, "value") + }) + } +} + +func TestDynamicRepeatedRulesEndToEnd(t *testing.T) { + data := []struct { + name string + typ *descriptorpb.FieldDescriptorProto_Type + rule *validate.FieldRules + info dynamicMessageTesterInfo + goodValue []int32 + badValue []int32 + }{ + { + name: "repeated_min_items", + typ: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + rule: validate.FieldRules_builder{ + Repeated: validate.RepeatedRules_builder{MinItems: proto.Uint64(2)}.Build(), + }.Build(), + goodValue: []int32{1, 2, 3}, + badValue: []int32{1}, + info: dynamicMessageTesterInfo{ + failedRuleID: "repeated.min_items", + failedRuleMessage: "must contain at least 2 item(s)", + }, + }, + { + name: "repeated_max_items", + typ: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + rule: validate.FieldRules_builder{ + Repeated: validate.RepeatedRules_builder{MaxItems: proto.Uint64(2)}.Build(), + }.Build(), + goodValue: []int32{1}, + badValue: []int32{1, 2, 3}, + info: dynamicMessageTesterInfo{ + failedRuleID: "repeated.max_items", + failedRuleMessage: "must contain no more than 2 item(s)", + }, + }, + { + name: "repeated_unique", + typ: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + rule: validate.FieldRules_builder{ + Repeated: validate.RepeatedRules_builder{Unique: ptr(true)}.Build(), + }.Build(), + goodValue: []int32{1, 2, 3}, + badValue: []int32{1, 2, 1}, + info: dynamicMessageTesterInfo{ + failedRuleID: "repeated.unique", + failedRuleMessage: "repeated value must contain unique items", + }, + }, + { + name: "repeated_unique_items", + typ: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + rule: validate.FieldRules_builder{ + Repeated: validate.RepeatedRules_builder{Unique: proto.Bool(true), Items: validate.FieldRules_builder{Int32: validate.Int32Rules_builder{Gte: ptr(int32(2))}.Build()}.Build()}.Build(), + }.Build(), + goodValue: []int32{2, 4, 6}, + badValue: []int32{2, 1, 3}, + info: dynamicMessageTesterInfo{ + failedRuleID: "int32.gte", + failedRuleMessage: "must be greater than or equal to 2", + }, + }, + { + name: "repeated_unique_max_items", + typ: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + rule: validate.FieldRules_builder{ + Repeated: validate.RepeatedRules_builder{Unique: proto.Bool(true), MaxItems: proto.Uint64(3), Items: validate.FieldRules_builder{Int32: validate.Int32Rules_builder{Gte: ptr(int32(2))}.Build()}.Build()}.Build(), + }.Build(), + goodValue: []int32{2, 4, 6}, + badValue: []int32{2, 6, 3, 5}, + info: dynamicMessageTesterInfo{ + failedRuleID: "repeated.max_items", + failedRuleMessage: "must contain no more than 3 item(s)", + }, + }, + } + for _, d := range data { + t.Run(d.name, func(t *testing.T) { + // we are setting an environment variable to enable native rules, so we can't use parallel tests + msgType := newDynamicMessageType(t, "test.native", "TestMessage", &descriptorpb.FieldDescriptorProto{ + Name: proto.String("value"), + Number: proto.Int32(1), + Type: d.typ, + Label: descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum(), + Options: fieldOpts(d.rule), + }) + d.info.msgType = msgType + // make lists + template := dynamicpb.NewMessage(msgType.Descriptor()) + goodList := template.NewField(msgType.Descriptor().Fields().ByName("value")) + for _, v := range d.goodValue { + goodList.List().Append(protoreflect.ValueOfInt32(v)) + } + d.info.goodValue = goodList + badList := template.NewField(msgType.Descriptor().Fields().ByName("value")) + for _, v := range d.badValue { + badList.List().Append(protoreflect.ValueOfInt32(v)) + } + d.info.badValue = badList + + // first with CEL rules + t.Setenv("PV_NATIVE_RULES", "false") + dynamicMessageTester(t, d.info, "value") + // now with native rules to validate they produce identical results + t.Setenv("PV_NATIVE_RULES", "true") + dynamicMessageTester(t, d.info, "value") + }) + } +} + +func TestNativeEnum_EndToEnd(t *testing.T) { + // Build a proto with an enum field and const rule. + enumDesc := &descriptorpb.EnumDescriptorProto{ + Name: proto.String("TestEnum"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + {Name: proto.String("UNSPECIFIED"), Number: proto.Int32(0)}, + {Name: proto.String("VALUE_ONE"), Number: proto.Int32(1)}, + {Name: proto.String("VALUE_TWO"), Number: proto.Int32(2)}, + }, + } + data := []struct { + name string + rule *validate.FieldRules + info dynamicMessageTesterInfo + }{ + { + name: "enum_const", + rule: validate.FieldRules_builder{ + Enum: validate.EnumRules_builder{Const: proto.Int32(1)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfEnum(1), + badValue: protoreflect.ValueOfEnum(2), + failedRuleID: "enum.const", + failedRuleMessage: "must equal 1", + }, + }, + { + name: "enum_in", + rule: validate.FieldRules_builder{ + Enum: validate.EnumRules_builder{In: []int32{1, 2}}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + goodValue: protoreflect.ValueOfEnum(1), + badValue: protoreflect.ValueOfEnum(3), + failedRuleID: "enum.in", + failedRuleMessage: "must be in list [1, 2]", + }, + }, + } + for _, d := range data { + t.Run(d.name, func(t *testing.T) { + msgType := newDynamicMessageTypeWithEnum(t, "test.native", "EnumMsg", enumDesc, &descriptorpb.FieldDescriptorProto{ + Name: proto.String("value"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_ENUM.Enum(), + TypeName: proto.String(".test.native.TestEnum"), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Options: fieldOpts(d.rule), + }) + d.info.msgType = msgType + // first with CEL rules + t.Setenv("PV_NATIVE_RULES", "false") + dynamicMessageTester(t, d.info, "value") + // now with native rules to validate they produce identical results + t.Setenv("PV_NATIVE_RULES", "true") + dynamicMessageTester(t, d.info, "value") + }) + } +} + +func TestNativeMap_EndToEnd(t *testing.T) { + data := []struct { + name string + rule *validate.FieldRules + info dynamicMessageTesterInfo + goodMap map[string]string + badMap map[string]string + }{ + { + name: "map_min_pairs", + rule: validate.FieldRules_builder{ + Map: validate.MapRules_builder{MinPairs: proto.Uint64(2)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + failedRuleID: "map.min_pairs", + failedRuleMessage: "map must be at least 2 entries", + }, + goodMap: map[string]string{"a": "1", "b": "2"}, + badMap: map[string]string{"a": "1"}, + }, + { + name: "map_max_pairs", + rule: validate.FieldRules_builder{ + Map: validate.MapRules_builder{MaxPairs: proto.Uint64(2)}.Build(), + }.Build(), + info: dynamicMessageTesterInfo{ + failedRuleID: "map.max_pairs", + failedRuleMessage: "map must be at most 2 entries", + }, + goodMap: map[string]string{"a": "1", "b": "2"}, + badMap: map[string]string{"a": "1", "b": "2", "c": "3"}, + }, + } + for _, d := range data { + t.Run(d.name, func(t *testing.T) { + msgType := newDynamicMapMessageType(t, "test.native", "MapMsg", + descriptorpb.FieldDescriptorProto_TYPE_STRING, + descriptorpb.FieldDescriptorProto_TYPE_STRING, + d.rule, + ) + + d.info.msgType = msgType + + template := dynamicpb.NewMessage(msgType.Descriptor()) + fd := msgType.Descriptor().Fields().ByName("entries") + maker := func(m map[string]string) protoreflect.Value { + mapField := template.NewField(fd) + for k, v := range m { + mapField.Map().Set( + protoreflect.ValueOfString(k).MapKey(), + protoreflect.ValueOfString(v), + ) + } + return mapField + } + // make a good map + d.info.goodValue = maker(d.goodMap) + // make a bad map + d.info.badValue = maker(d.badMap) + + // first with CEL rules + t.Setenv("PV_NATIVE_RULES", "false") + dynamicMessageTester(t, d.info, "entries") + // now with native rules to validate they produce identical results + t.Setenv("PV_NATIVE_RULES", "true") + dynamicMessageTester(t, d.info, "entries") + }) + } +} + +type dynamicMessageTesterInfo struct { + msgType protoreflect.MessageType + goodValue protoreflect.Value + badValue protoreflect.Value + failedRuleID string + failedRuleMessage string +} + +func dynamicMessageTester(t *testing.T, info dynamicMessageTesterInfo, fieldName protoreflect.Name) { + t.Helper() + validator, err := New(WithDisableLazy(), WithMessageDescriptors(info.msgType.Descriptor())) + require.NoError(t, err) + + passing := dynamicpb.NewMessage(info.msgType.Descriptor()) + passing.Set(info.msgType.Descriptor().Fields().ByName(fieldName), info.goodValue) + require.NoError(t, validator.Validate(passing)) + + failing := dynamicpb.NewMessage(info.msgType.Descriptor()) + failing.Set(info.msgType.Descriptor().Fields().ByName(fieldName), info.badValue) + err = validator.Validate(failing) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, info.failedRuleID, valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, info.failedRuleMessage, valErr.Violations[0].Proto.GetMessage()) +} + +// TestEnumCombinedRules validates that enum fields work correctly when +// multiple rules are active: native const/in/not_in (via processStandardRules) +// combined with native defined_only (via processEnumRules). +func TestEnumCombinedRules(t *testing.T) { + enumDesc := &descriptorpb.EnumDescriptorProto{ + Name: proto.String("Status"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + {Name: proto.String("STATUS_UNSPECIFIED"), Number: proto.Int32(0)}, + {Name: proto.String("STATUS_ACTIVE"), Number: proto.Int32(1)}, + {Name: proto.String("STATUS_INACTIVE"), Number: proto.Int32(2)}, + }, + } + + tests := []struct { + name string + rules *validate.EnumRules + value protoreflect.EnumNumber + wantErr bool + violationCount int + ruleIDs []string // expected rule IDs in order + }{ + // --- defined_only + in --- + { + name: "defined_only+in/pass: defined and in list", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), In: []int32{1, 2}}.Build(), + value: 1, // STATUS_ACTIVE: defined, in [1,2] + wantErr: false, + }, + { + name: "defined_only+in/fail_in_only: defined but not in list", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), In: []int32{1, 2}}.Build(), + value: 0, // STATUS_UNSPECIFIED: defined, not in [1,2] + wantErr: true, + violationCount: 1, + ruleIDs: []string{"enum.in"}, + }, + { + name: "defined_only+in/fail_both: undefined and not in list", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), In: []int32{1, 2}}.Build(), + value: 99, // undefined, not in [1,2] + wantErr: true, + violationCount: 2, + ruleIDs: []string{"enum.in", "enum.defined_only"}, + }, + + // --- defined_only + const --- + { + name: "defined_only+const/pass: defined and equals const", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), Const: proto.Int32(1)}.Build(), + value: 1, // STATUS_ACTIVE: defined, equals 1 + wantErr: false, + }, + { + name: "defined_only+const/fail_const_only: defined but wrong value", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), Const: proto.Int32(1)}.Build(), + value: 2, // STATUS_INACTIVE: defined, but not 1 + wantErr: true, + violationCount: 1, + ruleIDs: []string{"enum.const"}, + }, + { + name: "defined_only+const/fail_both: undefined and wrong value", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), Const: proto.Int32(1)}.Build(), + value: 99, // undefined, not 1 + wantErr: true, + violationCount: 2, + ruleIDs: []string{"enum.const", "enum.defined_only"}, + }, + + // --- defined_only + not_in --- + { + name: "defined_only+not_in/pass: defined and not in exclusion list", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), NotIn: []int32{0}}.Build(), + value: 1, // STATUS_ACTIVE: defined, not in [0] + wantErr: false, + }, + { + name: "defined_only+not_in/fail_not_in_only: defined but in exclusion list", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), NotIn: []int32{0}}.Build(), + value: 0, // STATUS_UNSPECIFIED: defined, but in [0] + wantErr: true, + violationCount: 1, + ruleIDs: []string{"enum.not_in"}, + }, + { + name: "defined_only+not_in/fail_defined_only: undefined but not in exclusion list", + rules: validate.EnumRules_builder{DefinedOnly: proto.Bool(true), NotIn: []int32{0}}.Build(), + value: 99, // undefined, but 99 is not in [0] + wantErr: true, + violationCount: 1, + ruleIDs: []string{"enum.defined_only"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgType := newDynamicMessageTypeWithEnum(t, "test.combined", "EnumCombined", enumDesc, &descriptorpb.FieldDescriptorProto{ + Name: proto.String("status"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_ENUM.Enum(), + TypeName: proto.String(".test.combined.Status"), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Options: fieldOpts(validate.FieldRules_builder{Enum: tt.rules}.Build()), + }) + + // Run with both CEL and native to verify identical results. + for _, mode := range []string{"false", "true"} { + t.Run("native="+mode, func(t *testing.T) { + t.Setenv("PV_NATIVE_RULES", mode) + + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) + require.NoError(t, err) + + msg := dynamicpb.NewMessage(msgType.Descriptor()) + msg.Set(msgType.Descriptor().Fields().ByName("status"), protoreflect.ValueOfEnum(tt.value)) + + err = validator.Validate(msg) + if !tt.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, tt.violationCount, + "expected %d violations, got %d", tt.violationCount, len(valErr.Violations)) + for i, expectedID := range tt.ruleIDs { + assert.Equal(t, expectedID, valErr.Violations[i].Proto.GetRuleId(), + "violation[%d] rule ID mismatch", i) + } + }) + } + }) + } +} + +// newDynamicMessageTypeWithEnum creates a dynamic message type that includes +// an enum type definition. +func newDynamicMessageTypeWithEnum( + t testing.TB, + pkg, name string, + enumDesc *descriptorpb.EnumDescriptorProto, + field *descriptorpb.FieldDescriptorProto, +) protoreflect.MessageType { + t.Helper() + + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String(pkg + "." + name + ".proto"), + Package: proto.String(pkg), + Syntax: proto.String("proto3"), + Dependency: []string{ + "buf/validate/validate.proto", + }, + EnumType: []*descriptorpb.EnumDescriptorProto{enumDesc}, + MessageType: []*descriptorpb.DescriptorProto{{ + Name: proto.String(name), + Field: []*descriptorpb.FieldDescriptorProto{field}, + }}, + } + + registry := newRegistryWithValidateProto(t) + fd, err := protodesc.FileOptions{}.New(file, registry) + require.NoError(t, err) + + desc := fd.Messages().ByName(protoreflect.Name(name)) + require.NotNil(t, desc) + + return dynamicpb.NewMessageType(desc) +} diff --git a/internal/gen/tests/example/v1/native_test.pb.go b/internal/gen/tests/example/v1/native_test.pb.go new file mode 100644 index 00000000..dcc90686 --- /dev/null +++ b/internal/gen/tests/example/v1/native_test.pb.go @@ -0,0 +1,759 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: tests/example/v1/native_test.proto + +//go:build !protoopaque + +package examplev1 + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + _ "github.com/rodaine/protogofakeit/gen/gofakeit" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type TestEnum int32 + +const ( + TestEnum_TEST_ENUM_UNSPECIFIED TestEnum = 0 + TestEnum_TEST_ENUM_VAL1 TestEnum = 1 + TestEnum_TEST_ENUM_VAL2 TestEnum = 2 + TestEnum_TEST_ENUM_VAL3 TestEnum = 3 +) + +// Enum value maps for TestEnum. +var ( + TestEnum_name = map[int32]string{ + 0: "TEST_ENUM_UNSPECIFIED", + 1: "TEST_ENUM_VAL1", + 2: "TEST_ENUM_VAL2", + 3: "TEST_ENUM_VAL3", + } + TestEnum_value = map[string]int32{ + "TEST_ENUM_UNSPECIFIED": 0, + "TEST_ENUM_VAL1": 1, + "TEST_ENUM_VAL2": 2, + "TEST_ENUM_VAL3": 3, + } +) + +func (x TestEnum) Enum() *TestEnum { + p := new(TestEnum) + *p = x + return p +} + +func (x TestEnum) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TestEnum) Descriptor() protoreflect.EnumDescriptor { + return file_tests_example_v1_native_test_proto_enumTypes[0].Descriptor() +} + +func (TestEnum) Type() protoreflect.EnumType { + return &file_tests_example_v1_native_test_proto_enumTypes[0] +} + +func (x TestEnum) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +type BenchGT struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Gt int32 `protobuf:"varint,1,opt,name=gt,proto3" json:"gt,omitempty"` + Gte int32 `protobuf:"varint,2,opt,name=gte,proto3" json:"gte,omitempty"` + Lt int32 `protobuf:"varint,3,opt,name=lt,proto3" json:"lt,omitempty"` + Lte int32 `protobuf:"varint,4,opt,name=lte,proto3" json:"lte,omitempty"` + Gtltin int32 `protobuf:"varint,5,opt,name=gtltin,proto3" json:"gtltin,omitempty"` + Gtltein int32 `protobuf:"varint,6,opt,name=gtltein,proto3" json:"gtltein,omitempty"` + Gtltex int32 `protobuf:"varint,7,opt,name=gtltex,proto3" json:"gtltex,omitempty"` + Gtlteex int32 `protobuf:"varint,8,opt,name=gtlteex,proto3" json:"gtlteex,omitempty"` + Gteltin int32 `protobuf:"varint,9,opt,name=gteltin,proto3" json:"gteltin,omitempty"` + Gteltein int32 `protobuf:"varint,10,opt,name=gteltein,proto3" json:"gteltein,omitempty"` + Gteltex int32 `protobuf:"varint,11,opt,name=gteltex,proto3" json:"gteltex,omitempty"` + Gtelteex int32 `protobuf:"varint,12,opt,name=gtelteex,proto3" json:"gtelteex,omitempty"` + Const int32 `protobuf:"varint,13,opt,name=const,proto3" json:"const,omitempty"` + Constgt int32 `protobuf:"varint,14,opt,name=constgt,proto3" json:"constgt,omitempty"` + InTest int32 `protobuf:"varint,15,opt,name=in_test,json=inTest,proto3" json:"in_test,omitempty"` + NotInTest int32 `protobuf:"varint,16,opt,name=not_in_test,json=notInTest,proto3" json:"not_in_test,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchGT) Reset() { + *x = BenchGT{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchGT) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchGT) ProtoMessage() {} + +func (x *BenchGT) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchGT) GetGt() int32 { + if x != nil { + return x.Gt + } + return 0 +} + +func (x *BenchGT) GetGte() int32 { + if x != nil { + return x.Gte + } + return 0 +} + +func (x *BenchGT) GetLt() int32 { + if x != nil { + return x.Lt + } + return 0 +} + +func (x *BenchGT) GetLte() int32 { + if x != nil { + return x.Lte + } + return 0 +} + +func (x *BenchGT) GetGtltin() int32 { + if x != nil { + return x.Gtltin + } + return 0 +} + +func (x *BenchGT) GetGtltein() int32 { + if x != nil { + return x.Gtltein + } + return 0 +} + +func (x *BenchGT) GetGtltex() int32 { + if x != nil { + return x.Gtltex + } + return 0 +} + +func (x *BenchGT) GetGtlteex() int32 { + if x != nil { + return x.Gtlteex + } + return 0 +} + +func (x *BenchGT) GetGteltin() int32 { + if x != nil { + return x.Gteltin + } + return 0 +} + +func (x *BenchGT) GetGteltein() int32 { + if x != nil { + return x.Gteltein + } + return 0 +} + +func (x *BenchGT) GetGteltex() int32 { + if x != nil { + return x.Gteltex + } + return 0 +} + +func (x *BenchGT) GetGtelteex() int32 { + if x != nil { + return x.Gtelteex + } + return 0 +} + +func (x *BenchGT) GetConst() int32 { + if x != nil { + return x.Const + } + return 0 +} + +func (x *BenchGT) GetConstgt() int32 { + if x != nil { + return x.Constgt + } + return 0 +} + +func (x *BenchGT) GetInTest() int32 { + if x != nil { + return x.InTest + } + return 0 +} + +func (x *BenchGT) GetNotInTest() int32 { + if x != nil { + return x.NotInTest + } + return 0 +} + +func (x *BenchGT) SetGt(v int32) { + x.Gt = v +} + +func (x *BenchGT) SetGte(v int32) { + x.Gte = v +} + +func (x *BenchGT) SetLt(v int32) { + x.Lt = v +} + +func (x *BenchGT) SetLte(v int32) { + x.Lte = v +} + +func (x *BenchGT) SetGtltin(v int32) { + x.Gtltin = v +} + +func (x *BenchGT) SetGtltein(v int32) { + x.Gtltein = v +} + +func (x *BenchGT) SetGtltex(v int32) { + x.Gtltex = v +} + +func (x *BenchGT) SetGtlteex(v int32) { + x.Gtlteex = v +} + +func (x *BenchGT) SetGteltin(v int32) { + x.Gteltin = v +} + +func (x *BenchGT) SetGteltein(v int32) { + x.Gteltein = v +} + +func (x *BenchGT) SetGteltex(v int32) { + x.Gteltex = v +} + +func (x *BenchGT) SetGtelteex(v int32) { + x.Gtelteex = v +} + +func (x *BenchGT) SetConst(v int32) { + x.Const = v +} + +func (x *BenchGT) SetConstgt(v int32) { + x.Constgt = v +} + +func (x *BenchGT) SetInTest(v int32) { + x.InTest = v +} + +func (x *BenchGT) SetNotInTest(v int32) { + x.NotInTest = v +} + +type BenchGT_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Gt int32 + Gte int32 + Lt int32 + Lte int32 + Gtltin int32 + Gtltein int32 + Gtltex int32 + Gtlteex int32 + Gteltin int32 + Gteltein int32 + Gteltex int32 + Gtelteex int32 + Const int32 + Constgt int32 + InTest int32 + NotInTest int32 +} + +func (b0 BenchGT_builder) Build() *BenchGT { + m0 := &BenchGT{} + b, x := &b0, m0 + _, _ = b, x + x.Gt = b.Gt + x.Gte = b.Gte + x.Lt = b.Lt + x.Lte = b.Lte + x.Gtltin = b.Gtltin + x.Gtltein = b.Gtltein + x.Gtltex = b.Gtltex + x.Gtlteex = b.Gtlteex + x.Gteltin = b.Gteltin + x.Gteltein = b.Gteltein + x.Gteltex = b.Gteltex + x.Gtelteex = b.Gtelteex + x.Const = b.Const + x.Constgt = b.Constgt + x.InTest = b.InTest + x.NotInTest = b.NotInTest + return m0 +} + +type BenchTestBytes struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + B1 []byte `protobuf:"bytes,1,opt,name=b1,proto3" json:"b1,omitempty"` + B []byte `protobuf:"bytes,4,opt,name=b,proto3" json:"b,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchTestBytes) Reset() { + *x = BenchTestBytes{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchTestBytes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchTestBytes) ProtoMessage() {} + +func (x *BenchTestBytes) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchTestBytes) GetB1() []byte { + if x != nil { + return x.B1 + } + return nil +} + +func (x *BenchTestBytes) GetB() []byte { + if x != nil { + return x.B + } + return nil +} + +func (x *BenchTestBytes) SetB1(v []byte) { + if v == nil { + v = []byte{} + } + x.B1 = v +} + +func (x *BenchTestBytes) SetB(v []byte) { + if v == nil { + v = []byte{} + } + x.B = v +} + +type BenchTestBytes_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + B1 []byte + B []byte +} + +func (b0 BenchTestBytes_builder) Build() *BenchTestBytes { + m0 := &BenchTestBytes{} + b, x := &b0, m0 + _, _ = b, x + x.B1 = b.B1 + x.B = b.B + return m0 +} + +type TestUnique struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Enums []TestEnum `protobuf:"varint,1,rep,packed,name=enums,proto3,enum=tests.example.v1.TestEnum" json:"enums,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestUnique) Reset() { + *x = TestUnique{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestUnique) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestUnique) ProtoMessage() {} + +func (x *TestUnique) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *TestUnique) GetEnums() []TestEnum { + if x != nil { + return x.Enums + } + return nil +} + +func (x *TestUnique) SetEnums(v []TestEnum) { + x.Enums = v +} + +type TestUnique_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Enums []TestEnum +} + +func (b0 TestUnique_builder) Build() *TestUnique { + m0 := &TestUnique{} + b, x := &b0, m0 + _, _ = b, x + x.Enums = b.Enums + return m0 +} + +type TestByteMatching struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + IpAddr []byte `protobuf:"bytes,1,opt,name=ip_addr,json=ipAddr,proto3" json:"ip_addr,omitempty"` + Ipv4Addr []byte `protobuf:"bytes,2,opt,name=ipv4_addr,json=ipv4Addr,proto3" json:"ipv4_addr,omitempty"` + Ipv6Addr []byte `protobuf:"bytes,3,opt,name=ipv6_addr,json=ipv6Addr,proto3" json:"ipv6_addr,omitempty"` + Uuid []byte `protobuf:"bytes,4,opt,name=uuid,proto3" json:"uuid,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestByteMatching) Reset() { + *x = TestByteMatching{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestByteMatching) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestByteMatching) ProtoMessage() {} + +func (x *TestByteMatching) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *TestByteMatching) GetIpAddr() []byte { + if x != nil { + return x.IpAddr + } + return nil +} + +func (x *TestByteMatching) GetIpv4Addr() []byte { + if x != nil { + return x.Ipv4Addr + } + return nil +} + +func (x *TestByteMatching) GetIpv6Addr() []byte { + if x != nil { + return x.Ipv6Addr + } + return nil +} + +func (x *TestByteMatching) GetUuid() []byte { + if x != nil { + return x.Uuid + } + return nil +} + +func (x *TestByteMatching) SetIpAddr(v []byte) { + if v == nil { + v = []byte{} + } + x.IpAddr = v +} + +func (x *TestByteMatching) SetIpv4Addr(v []byte) { + if v == nil { + v = []byte{} + } + x.Ipv4Addr = v +} + +func (x *TestByteMatching) SetIpv6Addr(v []byte) { + if v == nil { + v = []byte{} + } + x.Ipv6Addr = v +} + +func (x *TestByteMatching) SetUuid(v []byte) { + if v == nil { + v = []byte{} + } + x.Uuid = v +} + +type TestByteMatching_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + IpAddr []byte + Ipv4Addr []byte + Ipv6Addr []byte + Uuid []byte +} + +func (b0 TestByteMatching_builder) Build() *TestByteMatching { + m0 := &TestByteMatching{} + b, x := &b0, m0 + _, _ = b, x + x.IpAddr = b.IpAddr + x.Ipv4Addr = b.Ipv4Addr + x.Ipv6Addr = b.Ipv6Addr + x.Uuid = b.Uuid + return m0 +} + +type StringMatching struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` + HostAndPort string `protobuf:"bytes,2,opt,name=host_and_port,json=hostAndPort,proto3" json:"host_and_port,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StringMatching) Reset() { + *x = StringMatching{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StringMatching) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StringMatching) ProtoMessage() {} + +func (x *StringMatching) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *StringMatching) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +func (x *StringMatching) GetHostAndPort() string { + if x != nil { + return x.HostAndPort + } + return "" +} + +func (x *StringMatching) SetHostname(v string) { + x.Hostname = v +} + +func (x *StringMatching) SetHostAndPort(v string) { + x.HostAndPort = v +} + +type StringMatching_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Hostname string + HostAndPort string +} + +func (b0 StringMatching_builder) Build() *StringMatching { + m0 := &StringMatching{} + b, x := &b0, m0 + _, _ = b, x + x.Hostname = b.Hostname + x.HostAndPort = b.HostAndPort + return m0 +} + +var File_tests_example_v1_native_test_proto protoreflect.FileDescriptor + +const file_tests_example_v1_native_test_proto_rawDesc = "" + + "\n" + + "\"tests/example/v1/native_test.proto\x12\x10tests.example.v1\x1a\x1bbuf/validate/validate.proto\x1a\x17gofakeit/gofakeit.proto\"\xb0\a\n" + + "\aBenchGT\x12-\n" + + "\x02gt\x18\x01 \x01(\x05B\x1d\xbaH\x04\x1a\x02 \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x02gt\x12/\n" + + "\x03gte\x18\x02 \x01(\x05B\x1d\xbaH\x04\x1a\x02(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x03gte\x12-\n" + + "\x02lt\x18\x03 \x01(\x05B\x1d\xbaH\x04\x1a\x02\x10e\xca\xe66\x12\x12\x10{intrange:1,100}R\x02lt\x12/\n" + + "\x03lte\x18\x04 \x01(\x05B\x1d\xbaH\x04\x1a\x02\x18e\xca\xe66\x12\x12\x10{intrange:1,100}R\x03lte\x127\n" + + "\x06gtltin\x18\x05 \x01(\x05B\x1f\xbaH\x06\x1a\x04\x10e \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x06gtltin\x129\n" + + "\agtltein\x18\x06 \x01(\x05B\x1f\xbaH\x06\x1a\x04\x10e \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\agtltein\x12@\n" + + "\x06gtltex\x18\a \x01(\x05B(\xbaH\x0f\x1a\r\x10\xec\xff\xff\xff\xff\xff\xff\xff\xff\x01 \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x06gtltex\x12B\n" + + "\agtlteex\x18\b \x01(\x05B(\xbaH\x0f\x1a\r\x18\xec\xff\xff\xff\xff\xff\xff\xff\xff\x01 \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\agtlteex\x129\n" + + "\agteltin\x18\t \x01(\x05B\x1f\xbaH\x06\x1a\x04\x10e(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\agteltin\x12;\n" + + "\bgteltein\x18\n" + + " \x01(\x05B\x1f\xbaH\x06\x1a\x04\x10e(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\bgteltein\x12B\n" + + "\agteltex\x18\v \x01(\x05B(\xbaH\x0f\x1a\r\x10\xec\xff\xff\xff\xff\xff\xff\xff\xff\x01(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\agteltex\x12D\n" + + "\bgtelteex\x18\f \x01(\x05B(\xbaH\x0f\x1a\r\x18\xec\xff\xff\xff\xff\xff\xff\xff\xff\x01(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\bgtelteex\x123\n" + + "\x05const\x18\r \x01(\x05B\x1d\xbaH\x04\x1a\x02\b\n" + + "\xca\xe66\x12\x12\x10{intrange:10,10}R\x05const\x129\n" + + "\aconstgt\x18\x0e \x01(\x05B\x1f\xbaH\x06\x1a\x04\b\n" + + "(\x00\xca\xe66\x12\x12\x10{intrange:10,10}R\aconstgt\x128\n" + + "\ain_test\x18\x0f \x01(\x05B\x1f\xbaH\b\x1a\x060\x010\x030\x05\xca\xe66\x10\x12\x0e{intrange:3,3}R\x06inTest\x12?\n" + + "\vnot_in_test\x18\x10 \x01(\x05B\x1f\xbaH\b\x1a\x068\x018\x038\x05\xca\xe66\x10\x12\x0e{intrange:4,4}R\tnotInTest\"\\\n" + + "\x0eBenchTestBytes\x12%\n" + + "\x02b1\x18\x01 \x01(\fB\x15\xbaH\x12z\x10\x10\x01\x18\x04J\x0223J\x0245J\x0267R\x02b1\x12#\n" + + "\x01b\x18\x04 \x01(\fB\x15\xbaH\x12z\x10\x10\x02\x18\x02B\x0223B\x0245B\x0267R\x01b\"H\n" + + "\n" + + "TestUnique\x12:\n" + + "\x05enums\x18\x01 \x03(\x0e2\x1a.tests.example.v1.TestEnumB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05enums\"\xe9\x01\n" + + "\x10TestByteMatching\x126\n" + + "\aip_addr\x18\x01 \x01(\fB\x1d\xbaH\x04z\x02P\x01\xca\xe66\x12\x12\x101234567890123456R\x06ipAddr\x12.\n" + + "\tipv4_addr\x18\x02 \x01(\fB\x11\xbaH\x04z\x02X\x01\xca\xe66\x06\x12\x041234R\bipv4Addr\x12:\n" + + "\tipv6_addr\x18\x03 \x01(\fB\x1d\xbaH\x04z\x02`\x01\xca\xe66\x12\x12\x101234567890123456R\bipv6Addr\x121\n" + + "\x04uuid\x18\x04 \x01(\fB\x1d\xbaH\x04z\x02x\x01\xca\xe66\x12\x12\x101234567890123456R\x04uuid\"\x8c\x01\n" + + "\x0eStringMatching\x125\n" + + "\bhostname\x18\x01 \x01(\tB\x19\xbaH\x04r\x02h\x01\xca\xe66\x0e\x12\f{domainname}R\bhostname\x12C\n" + + "\rhost_and_port\x18\x02 \x01(\tB\x1f\xbaH\x05r\x03\x80\x02\x01\xca\xe66\x13\x12\x11{domainname}:8080R\vhostAndPort*a\n" + + "\bTestEnum\x12\x19\n" + + "\x15TEST_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eTEST_ENUM_VAL1\x10\x01\x12\x12\n" + + "\x0eTEST_ENUM_VAL2\x10\x02\x12\x12\n" + + "\x0eTEST_ENUM_VAL3\x10\x03B\xcd\x01\n" + + "\x14com.tests.example.v1B\x0fNativeTestProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" + +var file_tests_example_v1_native_test_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_tests_example_v1_native_test_proto_goTypes = []any{ + (TestEnum)(0), // 0: tests.example.v1.TestEnum + (*BenchGT)(nil), // 1: tests.example.v1.BenchGT + (*BenchTestBytes)(nil), // 2: tests.example.v1.BenchTestBytes + (*TestUnique)(nil), // 3: tests.example.v1.TestUnique + (*TestByteMatching)(nil), // 4: tests.example.v1.TestByteMatching + (*StringMatching)(nil), // 5: tests.example.v1.StringMatching +} +var file_tests_example_v1_native_test_proto_depIdxs = []int32{ + 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_tests_example_v1_native_test_proto_init() } +func file_tests_example_v1_native_test_proto_init() { + if File_tests_example_v1_native_test_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_native_test_proto_rawDesc), len(file_tests_example_v1_native_test_proto_rawDesc)), + NumEnums: 1, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_tests_example_v1_native_test_proto_goTypes, + DependencyIndexes: file_tests_example_v1_native_test_proto_depIdxs, + EnumInfos: file_tests_example_v1_native_test_proto_enumTypes, + MessageInfos: file_tests_example_v1_native_test_proto_msgTypes, + }.Build() + File_tests_example_v1_native_test_proto = out.File + file_tests_example_v1_native_test_proto_goTypes = nil + file_tests_example_v1_native_test_proto_depIdxs = nil +} diff --git a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go new file mode 100644 index 00000000..8696a56f --- /dev/null +++ b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go @@ -0,0 +1,759 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.6 +// protoc (unknown) +// source: tests/example/v1/native_test.proto + +//go:build protoopaque + +package examplev1 + +import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + _ "github.com/rodaine/protogofakeit/gen/gofakeit" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type TestEnum int32 + +const ( + TestEnum_TEST_ENUM_UNSPECIFIED TestEnum = 0 + TestEnum_TEST_ENUM_VAL1 TestEnum = 1 + TestEnum_TEST_ENUM_VAL2 TestEnum = 2 + TestEnum_TEST_ENUM_VAL3 TestEnum = 3 +) + +// Enum value maps for TestEnum. +var ( + TestEnum_name = map[int32]string{ + 0: "TEST_ENUM_UNSPECIFIED", + 1: "TEST_ENUM_VAL1", + 2: "TEST_ENUM_VAL2", + 3: "TEST_ENUM_VAL3", + } + TestEnum_value = map[string]int32{ + "TEST_ENUM_UNSPECIFIED": 0, + "TEST_ENUM_VAL1": 1, + "TEST_ENUM_VAL2": 2, + "TEST_ENUM_VAL3": 3, + } +) + +func (x TestEnum) Enum() *TestEnum { + p := new(TestEnum) + *p = x + return p +} + +func (x TestEnum) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TestEnum) Descriptor() protoreflect.EnumDescriptor { + return file_tests_example_v1_native_test_proto_enumTypes[0].Descriptor() +} + +func (TestEnum) Type() protoreflect.EnumType { + return &file_tests_example_v1_native_test_proto_enumTypes[0] +} + +func (x TestEnum) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +type BenchGT struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Gt int32 `protobuf:"varint,1,opt,name=gt,proto3"` + xxx_hidden_Gte int32 `protobuf:"varint,2,opt,name=gte,proto3"` + xxx_hidden_Lt int32 `protobuf:"varint,3,opt,name=lt,proto3"` + xxx_hidden_Lte int32 `protobuf:"varint,4,opt,name=lte,proto3"` + xxx_hidden_Gtltin int32 `protobuf:"varint,5,opt,name=gtltin,proto3"` + xxx_hidden_Gtltein int32 `protobuf:"varint,6,opt,name=gtltein,proto3"` + xxx_hidden_Gtltex int32 `protobuf:"varint,7,opt,name=gtltex,proto3"` + xxx_hidden_Gtlteex int32 `protobuf:"varint,8,opt,name=gtlteex,proto3"` + xxx_hidden_Gteltin int32 `protobuf:"varint,9,opt,name=gteltin,proto3"` + xxx_hidden_Gteltein int32 `protobuf:"varint,10,opt,name=gteltein,proto3"` + xxx_hidden_Gteltex int32 `protobuf:"varint,11,opt,name=gteltex,proto3"` + xxx_hidden_Gtelteex int32 `protobuf:"varint,12,opt,name=gtelteex,proto3"` + xxx_hidden_Const int32 `protobuf:"varint,13,opt,name=const,proto3"` + xxx_hidden_Constgt int32 `protobuf:"varint,14,opt,name=constgt,proto3"` + xxx_hidden_InTest int32 `protobuf:"varint,15,opt,name=in_test,json=inTest,proto3"` + xxx_hidden_NotInTest int32 `protobuf:"varint,16,opt,name=not_in_test,json=notInTest,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchGT) Reset() { + *x = BenchGT{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchGT) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchGT) ProtoMessage() {} + +func (x *BenchGT) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchGT) GetGt() int32 { + if x != nil { + return x.xxx_hidden_Gt + } + return 0 +} + +func (x *BenchGT) GetGte() int32 { + if x != nil { + return x.xxx_hidden_Gte + } + return 0 +} + +func (x *BenchGT) GetLt() int32 { + if x != nil { + return x.xxx_hidden_Lt + } + return 0 +} + +func (x *BenchGT) GetLte() int32 { + if x != nil { + return x.xxx_hidden_Lte + } + return 0 +} + +func (x *BenchGT) GetGtltin() int32 { + if x != nil { + return x.xxx_hidden_Gtltin + } + return 0 +} + +func (x *BenchGT) GetGtltein() int32 { + if x != nil { + return x.xxx_hidden_Gtltein + } + return 0 +} + +func (x *BenchGT) GetGtltex() int32 { + if x != nil { + return x.xxx_hidden_Gtltex + } + return 0 +} + +func (x *BenchGT) GetGtlteex() int32 { + if x != nil { + return x.xxx_hidden_Gtlteex + } + return 0 +} + +func (x *BenchGT) GetGteltin() int32 { + if x != nil { + return x.xxx_hidden_Gteltin + } + return 0 +} + +func (x *BenchGT) GetGteltein() int32 { + if x != nil { + return x.xxx_hidden_Gteltein + } + return 0 +} + +func (x *BenchGT) GetGteltex() int32 { + if x != nil { + return x.xxx_hidden_Gteltex + } + return 0 +} + +func (x *BenchGT) GetGtelteex() int32 { + if x != nil { + return x.xxx_hidden_Gtelteex + } + return 0 +} + +func (x *BenchGT) GetConst() int32 { + if x != nil { + return x.xxx_hidden_Const + } + return 0 +} + +func (x *BenchGT) GetConstgt() int32 { + if x != nil { + return x.xxx_hidden_Constgt + } + return 0 +} + +func (x *BenchGT) GetInTest() int32 { + if x != nil { + return x.xxx_hidden_InTest + } + return 0 +} + +func (x *BenchGT) GetNotInTest() int32 { + if x != nil { + return x.xxx_hidden_NotInTest + } + return 0 +} + +func (x *BenchGT) SetGt(v int32) { + x.xxx_hidden_Gt = v +} + +func (x *BenchGT) SetGte(v int32) { + x.xxx_hidden_Gte = v +} + +func (x *BenchGT) SetLt(v int32) { + x.xxx_hidden_Lt = v +} + +func (x *BenchGT) SetLte(v int32) { + x.xxx_hidden_Lte = v +} + +func (x *BenchGT) SetGtltin(v int32) { + x.xxx_hidden_Gtltin = v +} + +func (x *BenchGT) SetGtltein(v int32) { + x.xxx_hidden_Gtltein = v +} + +func (x *BenchGT) SetGtltex(v int32) { + x.xxx_hidden_Gtltex = v +} + +func (x *BenchGT) SetGtlteex(v int32) { + x.xxx_hidden_Gtlteex = v +} + +func (x *BenchGT) SetGteltin(v int32) { + x.xxx_hidden_Gteltin = v +} + +func (x *BenchGT) SetGteltein(v int32) { + x.xxx_hidden_Gteltein = v +} + +func (x *BenchGT) SetGteltex(v int32) { + x.xxx_hidden_Gteltex = v +} + +func (x *BenchGT) SetGtelteex(v int32) { + x.xxx_hidden_Gtelteex = v +} + +func (x *BenchGT) SetConst(v int32) { + x.xxx_hidden_Const = v +} + +func (x *BenchGT) SetConstgt(v int32) { + x.xxx_hidden_Constgt = v +} + +func (x *BenchGT) SetInTest(v int32) { + x.xxx_hidden_InTest = v +} + +func (x *BenchGT) SetNotInTest(v int32) { + x.xxx_hidden_NotInTest = v +} + +type BenchGT_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Gt int32 + Gte int32 + Lt int32 + Lte int32 + Gtltin int32 + Gtltein int32 + Gtltex int32 + Gtlteex int32 + Gteltin int32 + Gteltein int32 + Gteltex int32 + Gtelteex int32 + Const int32 + Constgt int32 + InTest int32 + NotInTest int32 +} + +func (b0 BenchGT_builder) Build() *BenchGT { + m0 := &BenchGT{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Gt = b.Gt + x.xxx_hidden_Gte = b.Gte + x.xxx_hidden_Lt = b.Lt + x.xxx_hidden_Lte = b.Lte + x.xxx_hidden_Gtltin = b.Gtltin + x.xxx_hidden_Gtltein = b.Gtltein + x.xxx_hidden_Gtltex = b.Gtltex + x.xxx_hidden_Gtlteex = b.Gtlteex + x.xxx_hidden_Gteltin = b.Gteltin + x.xxx_hidden_Gteltein = b.Gteltein + x.xxx_hidden_Gteltex = b.Gteltex + x.xxx_hidden_Gtelteex = b.Gtelteex + x.xxx_hidden_Const = b.Const + x.xxx_hidden_Constgt = b.Constgt + x.xxx_hidden_InTest = b.InTest + x.xxx_hidden_NotInTest = b.NotInTest + return m0 +} + +type BenchTestBytes struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_B1 []byte `protobuf:"bytes,1,opt,name=b1,proto3"` + xxx_hidden_B []byte `protobuf:"bytes,4,opt,name=b,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BenchTestBytes) Reset() { + *x = BenchTestBytes{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BenchTestBytes) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BenchTestBytes) ProtoMessage() {} + +func (x *BenchTestBytes) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *BenchTestBytes) GetB1() []byte { + if x != nil { + return x.xxx_hidden_B1 + } + return nil +} + +func (x *BenchTestBytes) GetB() []byte { + if x != nil { + return x.xxx_hidden_B + } + return nil +} + +func (x *BenchTestBytes) SetB1(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_B1 = v +} + +func (x *BenchTestBytes) SetB(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_B = v +} + +type BenchTestBytes_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + B1 []byte + B []byte +} + +func (b0 BenchTestBytes_builder) Build() *BenchTestBytes { + m0 := &BenchTestBytes{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_B1 = b.B1 + x.xxx_hidden_B = b.B + return m0 +} + +type TestUnique struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Enums []TestEnum `protobuf:"varint,1,rep,packed,name=enums,proto3,enum=tests.example.v1.TestEnum"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestUnique) Reset() { + *x = TestUnique{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestUnique) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestUnique) ProtoMessage() {} + +func (x *TestUnique) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *TestUnique) GetEnums() []TestEnum { + if x != nil { + return x.xxx_hidden_Enums + } + return nil +} + +func (x *TestUnique) SetEnums(v []TestEnum) { + x.xxx_hidden_Enums = v +} + +type TestUnique_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Enums []TestEnum +} + +func (b0 TestUnique_builder) Build() *TestUnique { + m0 := &TestUnique{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Enums = b.Enums + return m0 +} + +type TestByteMatching struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_IpAddr []byte `protobuf:"bytes,1,opt,name=ip_addr,json=ipAddr,proto3"` + xxx_hidden_Ipv4Addr []byte `protobuf:"bytes,2,opt,name=ipv4_addr,json=ipv4Addr,proto3"` + xxx_hidden_Ipv6Addr []byte `protobuf:"bytes,3,opt,name=ipv6_addr,json=ipv6Addr,proto3"` + xxx_hidden_Uuid []byte `protobuf:"bytes,4,opt,name=uuid,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestByteMatching) Reset() { + *x = TestByteMatching{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestByteMatching) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestByteMatching) ProtoMessage() {} + +func (x *TestByteMatching) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *TestByteMatching) GetIpAddr() []byte { + if x != nil { + return x.xxx_hidden_IpAddr + } + return nil +} + +func (x *TestByteMatching) GetIpv4Addr() []byte { + if x != nil { + return x.xxx_hidden_Ipv4Addr + } + return nil +} + +func (x *TestByteMatching) GetIpv6Addr() []byte { + if x != nil { + return x.xxx_hidden_Ipv6Addr + } + return nil +} + +func (x *TestByteMatching) GetUuid() []byte { + if x != nil { + return x.xxx_hidden_Uuid + } + return nil +} + +func (x *TestByteMatching) SetIpAddr(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_IpAddr = v +} + +func (x *TestByteMatching) SetIpv4Addr(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_Ipv4Addr = v +} + +func (x *TestByteMatching) SetIpv6Addr(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_Ipv6Addr = v +} + +func (x *TestByteMatching) SetUuid(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_Uuid = v +} + +type TestByteMatching_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + IpAddr []byte + Ipv4Addr []byte + Ipv6Addr []byte + Uuid []byte +} + +func (b0 TestByteMatching_builder) Build() *TestByteMatching { + m0 := &TestByteMatching{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_IpAddr = b.IpAddr + x.xxx_hidden_Ipv4Addr = b.Ipv4Addr + x.xxx_hidden_Ipv6Addr = b.Ipv6Addr + x.xxx_hidden_Uuid = b.Uuid + return m0 +} + +type StringMatching struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3"` + xxx_hidden_HostAndPort string `protobuf:"bytes,2,opt,name=host_and_port,json=hostAndPort,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StringMatching) Reset() { + *x = StringMatching{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StringMatching) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StringMatching) ProtoMessage() {} + +func (x *StringMatching) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *StringMatching) GetHostname() string { + if x != nil { + return x.xxx_hidden_Hostname + } + return "" +} + +func (x *StringMatching) GetHostAndPort() string { + if x != nil { + return x.xxx_hidden_HostAndPort + } + return "" +} + +func (x *StringMatching) SetHostname(v string) { + x.xxx_hidden_Hostname = v +} + +func (x *StringMatching) SetHostAndPort(v string) { + x.xxx_hidden_HostAndPort = v +} + +type StringMatching_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Hostname string + HostAndPort string +} + +func (b0 StringMatching_builder) Build() *StringMatching { + m0 := &StringMatching{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Hostname = b.Hostname + x.xxx_hidden_HostAndPort = b.HostAndPort + return m0 +} + +var File_tests_example_v1_native_test_proto protoreflect.FileDescriptor + +const file_tests_example_v1_native_test_proto_rawDesc = "" + + "\n" + + "\"tests/example/v1/native_test.proto\x12\x10tests.example.v1\x1a\x1bbuf/validate/validate.proto\x1a\x17gofakeit/gofakeit.proto\"\xb0\a\n" + + "\aBenchGT\x12-\n" + + "\x02gt\x18\x01 \x01(\x05B\x1d\xbaH\x04\x1a\x02 \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x02gt\x12/\n" + + "\x03gte\x18\x02 \x01(\x05B\x1d\xbaH\x04\x1a\x02(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x03gte\x12-\n" + + "\x02lt\x18\x03 \x01(\x05B\x1d\xbaH\x04\x1a\x02\x10e\xca\xe66\x12\x12\x10{intrange:1,100}R\x02lt\x12/\n" + + "\x03lte\x18\x04 \x01(\x05B\x1d\xbaH\x04\x1a\x02\x18e\xca\xe66\x12\x12\x10{intrange:1,100}R\x03lte\x127\n" + + "\x06gtltin\x18\x05 \x01(\x05B\x1f\xbaH\x06\x1a\x04\x10e \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x06gtltin\x129\n" + + "\agtltein\x18\x06 \x01(\x05B\x1f\xbaH\x06\x1a\x04\x10e \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\agtltein\x12@\n" + + "\x06gtltex\x18\a \x01(\x05B(\xbaH\x0f\x1a\r\x10\xec\xff\xff\xff\xff\xff\xff\xff\xff\x01 \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x06gtltex\x12B\n" + + "\agtlteex\x18\b \x01(\x05B(\xbaH\x0f\x1a\r\x18\xec\xff\xff\xff\xff\xff\xff\xff\xff\x01 \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\agtlteex\x129\n" + + "\agteltin\x18\t \x01(\x05B\x1f\xbaH\x06\x1a\x04\x10e(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\agteltin\x12;\n" + + "\bgteltein\x18\n" + + " \x01(\x05B\x1f\xbaH\x06\x1a\x04\x10e(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\bgteltein\x12B\n" + + "\agteltex\x18\v \x01(\x05B(\xbaH\x0f\x1a\r\x10\xec\xff\xff\xff\xff\xff\xff\xff\xff\x01(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\agteltex\x12D\n" + + "\bgtelteex\x18\f \x01(\x05B(\xbaH\x0f\x1a\r\x18\xec\xff\xff\xff\xff\xff\xff\xff\xff\x01(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\bgtelteex\x123\n" + + "\x05const\x18\r \x01(\x05B\x1d\xbaH\x04\x1a\x02\b\n" + + "\xca\xe66\x12\x12\x10{intrange:10,10}R\x05const\x129\n" + + "\aconstgt\x18\x0e \x01(\x05B\x1f\xbaH\x06\x1a\x04\b\n" + + "(\x00\xca\xe66\x12\x12\x10{intrange:10,10}R\aconstgt\x128\n" + + "\ain_test\x18\x0f \x01(\x05B\x1f\xbaH\b\x1a\x060\x010\x030\x05\xca\xe66\x10\x12\x0e{intrange:3,3}R\x06inTest\x12?\n" + + "\vnot_in_test\x18\x10 \x01(\x05B\x1f\xbaH\b\x1a\x068\x018\x038\x05\xca\xe66\x10\x12\x0e{intrange:4,4}R\tnotInTest\"\\\n" + + "\x0eBenchTestBytes\x12%\n" + + "\x02b1\x18\x01 \x01(\fB\x15\xbaH\x12z\x10\x10\x01\x18\x04J\x0223J\x0245J\x0267R\x02b1\x12#\n" + + "\x01b\x18\x04 \x01(\fB\x15\xbaH\x12z\x10\x10\x02\x18\x02B\x0223B\x0245B\x0267R\x01b\"H\n" + + "\n" + + "TestUnique\x12:\n" + + "\x05enums\x18\x01 \x03(\x0e2\x1a.tests.example.v1.TestEnumB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05enums\"\xe9\x01\n" + + "\x10TestByteMatching\x126\n" + + "\aip_addr\x18\x01 \x01(\fB\x1d\xbaH\x04z\x02P\x01\xca\xe66\x12\x12\x101234567890123456R\x06ipAddr\x12.\n" + + "\tipv4_addr\x18\x02 \x01(\fB\x11\xbaH\x04z\x02X\x01\xca\xe66\x06\x12\x041234R\bipv4Addr\x12:\n" + + "\tipv6_addr\x18\x03 \x01(\fB\x1d\xbaH\x04z\x02`\x01\xca\xe66\x12\x12\x101234567890123456R\bipv6Addr\x121\n" + + "\x04uuid\x18\x04 \x01(\fB\x1d\xbaH\x04z\x02x\x01\xca\xe66\x12\x12\x101234567890123456R\x04uuid\"\x8c\x01\n" + + "\x0eStringMatching\x125\n" + + "\bhostname\x18\x01 \x01(\tB\x19\xbaH\x04r\x02h\x01\xca\xe66\x0e\x12\f{domainname}R\bhostname\x12C\n" + + "\rhost_and_port\x18\x02 \x01(\tB\x1f\xbaH\x05r\x03\x80\x02\x01\xca\xe66\x13\x12\x11{domainname}:8080R\vhostAndPort*a\n" + + "\bTestEnum\x12\x19\n" + + "\x15TEST_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eTEST_ENUM_VAL1\x10\x01\x12\x12\n" + + "\x0eTEST_ENUM_VAL2\x10\x02\x12\x12\n" + + "\x0eTEST_ENUM_VAL3\x10\x03B\xcd\x01\n" + + "\x14com.tests.example.v1B\x0fNativeTestProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" + +var file_tests_example_v1_native_test_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_tests_example_v1_native_test_proto_goTypes = []any{ + (TestEnum)(0), // 0: tests.example.v1.TestEnum + (*BenchGT)(nil), // 1: tests.example.v1.BenchGT + (*BenchTestBytes)(nil), // 2: tests.example.v1.BenchTestBytes + (*TestUnique)(nil), // 3: tests.example.v1.TestUnique + (*TestByteMatching)(nil), // 4: tests.example.v1.TestByteMatching + (*StringMatching)(nil), // 5: tests.example.v1.StringMatching +} +var file_tests_example_v1_native_test_proto_depIdxs = []int32{ + 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_tests_example_v1_native_test_proto_init() } +func file_tests_example_v1_native_test_proto_init() { + if File_tests_example_v1_native_test_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_native_test_proto_rawDesc), len(file_tests_example_v1_native_test_proto_rawDesc)), + NumEnums: 1, + NumMessages: 5, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_tests_example_v1_native_test_proto_goTypes, + DependencyIndexes: file_tests_example_v1_native_test_proto_depIdxs, + EnumInfos: file_tests_example_v1_native_test_proto_enumTypes, + MessageInfos: file_tests_example_v1_native_test_proto_msgTypes, + }.Build() + File_tests_example_v1_native_test_proto = out.File + file_tests_example_v1_native_test_proto_goTypes = nil + file_tests_example_v1_native_test_proto_depIdxs = nil +} diff --git a/native_bool.go b/native_bool.go new file mode 100644 index 00000000..80521da7 --- /dev/null +++ b/native_bool.go @@ -0,0 +1,67 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "fmt" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/reflect/protoreflect" +) + +//nolint:gochecknoglobals +var ( + boolRuleDesc = fieldRulesDesc.Fields().ByName("bool") + boolConstDesc = (*validate.BoolRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const") +) + +// nativeBoolEval is a native Go evaluator for bool const rules. +type nativeBoolEval struct { + base + constVal bool +} + +func (n nativeBoolEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + if val.Bool() != n.constVal { + return n.newViolation(boolRuleDesc, boolConstDesc, + "bool.const", fmt.Sprintf("must equal %t", n.constVal), + val, protoreflect.ValueOfBool(n.constVal)) + } + return nil +} + +func (n nativeBoolEval) Tautology() bool { + return false +} + +var _ evaluator = nativeBoolEval{} + +// tryBuildNativeBoolRules attempts to build a native Go evaluator for +// bool rules. Returns nil if the rules can't be handled natively. +func tryBuildNativeBoolRules(base base, rules *validate.BoolRules) evaluator { + if rules == nil { + return nil + } + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + if !rules.HasConst() { + return nil + } + return nativeBoolEval{ + base: base, + constVal: rules.GetConst(), + } +} diff --git a/native_bool_test.go b/native_bool_test.go new file mode 100644 index 00000000..c6a3b70f --- /dev/null +++ b/native_bool_test.go @@ -0,0 +1,93 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +func TestNativeBoolConst_True(t *testing.T) { + t.Parallel() + eval := buildNativeBool(t, validate.BoolRules_builder{Const: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBool(true), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfBool(false), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, "bool.const", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "must equal true", valErr.Violations[0].Proto.GetMessage()) +} + +func TestNativeBoolConst_False(t *testing.T) { + t.Parallel() + eval := buildNativeBool(t, validate.BoolRules_builder{Const: proto.Bool(false)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBool(false), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfBool(true), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "must equal false", valErr.Violations[0].Proto.GetMessage()) +} + +func TestTryBuildNativeBoolRules_ReturnsNil(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.BoolRules + }{ + {"nil_rules", nil}, + {"no_const", validate.BoolRules_builder{}.Build()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Nil(t, tryBuildNativeBoolRules(base{}, tt.rules)) + }) + } +} + +func TestNativeBoolTautology(t *testing.T) { + t.Parallel() + eval := buildNativeBool(t, validate.BoolRules_builder{Const: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + assert.False(t, eval.Tautology()) +} + +func buildNativeBool(t testing.TB, rules *validate.BoolRules) evaluator { + t.Helper() + fdesc := newFieldDescriptor(t, descriptorpb.FieldDescriptorProto_TYPE_BOOL, + descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum()) + b := base{ + Descriptor: fdesc, + FieldPathElement: fieldPathElement(fdesc), + } + return tryBuildNativeBoolRules(b, rules) +} diff --git a/native_bytes.go b/native_bytes.go new file mode 100644 index 00000000..fe70d300 --- /dev/null +++ b/native_bytes.go @@ -0,0 +1,391 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "bytes" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "unicode/utf8" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// bytesWellKnown identifies which well-known bytes format constraint is active. +type bytesWellKnown struct { + desc protoreflect.FieldDescriptor + ruleID, emptyRuleID string + mainMsg, emptyMsg string + validSizes []int +} + +var ( + //nolint:gochecknoglobals + bytesWellKnownIP = bytesWellKnown{ + desc: bytesDescs.ipDesc, + ruleID: "bytes.ip", + emptyRuleID: "bytes.ip_empty", + mainMsg: "must be a valid IP address", + emptyMsg: "value is empty, which is not a valid IP address", + validSizes: []int{4, 16}, + } + //nolint:gochecknoglobals + bytesWellKnownIPv4 = bytesWellKnown{ + desc: bytesDescs.ipv4Desc, + ruleID: "bytes.ipv4", + emptyRuleID: "bytes.ipv4_empty", + mainMsg: "must be a valid IPv4 address", + emptyMsg: "value is empty, which is not a valid IPv4 address", + validSizes: []int{4}, + } + //nolint:gochecknoglobals + bytesWellKnownIPv6 = bytesWellKnown{ + desc: bytesDescs.ipv6Desc, + ruleID: "bytes.ipv6", + emptyRuleID: "bytes.ipv6_empty", + mainMsg: "must be a valid IPv6 address", + emptyMsg: "value is empty, which is not a valid IPv6 address", + validSizes: []int{16}, + } + //nolint:gochecknoglobals + bytesWellKnownUUID = bytesWellKnown{ + desc: bytesDescs.uuidDesc, + ruleID: "bytes.uuid", + emptyRuleID: "bytes.uuid_empty", + mainMsg: "must be a valid UUID", + emptyMsg: "value is empty, which is not a valid UUID", + validSizes: []int{16}, + } +) + +// bytesDescriptors bundles the field descriptors for BytesRules. +type bytesDescriptors struct { + ruleDesc protoreflect.FieldDescriptor + constDesc protoreflect.FieldDescriptor + lenDesc protoreflect.FieldDescriptor + minLenDesc protoreflect.FieldDescriptor + maxLenDesc protoreflect.FieldDescriptor + patternDesc protoreflect.FieldDescriptor + prefixDesc protoreflect.FieldDescriptor + suffixDesc protoreflect.FieldDescriptor + containsDesc protoreflect.FieldDescriptor + inDesc protoreflect.FieldDescriptor + notInDesc protoreflect.FieldDescriptor + ipDesc protoreflect.FieldDescriptor + ipv4Desc protoreflect.FieldDescriptor + ipv6Desc protoreflect.FieldDescriptor + uuidDesc protoreflect.FieldDescriptor +} + +func makeBytesDescriptors() bytesDescriptors { + rulesDesc := (*validate.BytesRules)(nil).ProtoReflect().Descriptor() + return bytesDescriptors{ + ruleDesc: fieldRulesDesc.Fields().ByName("bytes"), + constDesc: rulesDesc.Fields().ByName("const"), + lenDesc: rulesDesc.Fields().ByName("len"), + minLenDesc: rulesDesc.Fields().ByName("min_len"), + maxLenDesc: rulesDesc.Fields().ByName("max_len"), + patternDesc: rulesDesc.Fields().ByName("pattern"), + prefixDesc: rulesDesc.Fields().ByName("prefix"), + suffixDesc: rulesDesc.Fields().ByName("suffix"), + containsDesc: rulesDesc.Fields().ByName("contains"), + inDesc: rulesDesc.Fields().ByName("in"), + notInDesc: rulesDesc.Fields().ByName("not_in"), + ipDesc: rulesDesc.Fields().ByName("ip"), + ipv4Desc: rulesDesc.Fields().ByName("ipv4"), + ipv6Desc: rulesDesc.Fields().ByName("ipv6"), + uuidDesc: rulesDesc.Fields().ByName("uuid"), + } +} + +//nolint:gochecknoglobals +var bytesDescs = makeBytesDescriptors() + +// nativeBytesEval is a native Go evaluator for bytes rules. +type nativeBytesEval struct { + base + constVal []byte + hasConst bool + exactLen *uint64 + minLen *uint64 + maxLen *uint64 + pattern *regexp.Regexp + patternStr string + prefix []byte + hasPrefix bool + suffix []byte + hasSuffix bool + contains []byte + hasContains bool + inVals [][]byte + notInVals [][]byte + wellKnown *bytesWellKnown +} + +var ( + errNotUTF8 = errors.New("must be valid UTF-8 to apply regexp") +) + +func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + bytesVal := val.Bytes() + byteLen := uint64(len(bytesVal)) + // const + if n.hasConst && !bytes.Equal(bytesVal, n.constVal) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.constDesc, + "bytes.const", fmt.Sprintf("must be %x", n.constVal), + val, protoreflect.ValueOfBytes(n.constVal)) + } + + // len + if n.exactLen != nil && byteLen != *n.exactLen { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.lenDesc, + "bytes.len", fmt.Sprintf("must be %d bytes", *n.exactLen), + val, protoreflect.ValueOfUint64(*n.exactLen)) + } + + // min_len + if n.minLen != nil && byteLen < *n.minLen { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.minLenDesc, + "bytes.min_len", fmt.Sprintf("must be at least %d bytes", *n.minLen), + val, protoreflect.ValueOfUint64(*n.minLen)) + } + + // max_len + if n.maxLen != nil && byteLen > *n.maxLen { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.maxLenDesc, + "bytes.max_len", fmt.Sprintf("must be at most %d bytes", *n.maxLen), + val, protoreflect.ValueOfUint64(*n.maxLen)) + } + + // pattern (matches against string conversion of bytes) + if n.pattern != nil { + if !utf8.Valid(bytesVal) { + return &RuntimeError{cause: errNotUTF8} + } + if !n.pattern.MatchString(string(bytesVal)) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.patternDesc, + "bytes.pattern", fmt.Sprintf("must match regex pattern `%s`", n.patternStr), + val, protoreflect.ValueOfString(n.patternStr)) + } + } + + // prefix + if n.hasPrefix && !bytes.HasPrefix(bytesVal, n.prefix) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.prefixDesc, + "bytes.prefix", fmt.Sprintf("does not have prefix %x", n.prefix), + val, protoreflect.ValueOfBytes(n.prefix)) + } + + // suffix + if n.hasSuffix && !bytes.HasSuffix(bytesVal, n.suffix) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.suffixDesc, + "bytes.suffix", fmt.Sprintf("does not have suffix %x", n.suffix), + val, protoreflect.ValueOfBytes(n.suffix)) + } + + // contains + if n.hasContains && !bytes.Contains(bytesVal, n.contains) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.containsDesc, + "bytes.contains", fmt.Sprintf("does not contain %x", n.contains), + val, protoreflect.ValueOfBytes(n.contains)) + } + + // in + if len(n.inVals) > 0 && !slices.ContainsFunc(n.inVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.inDesc, + "bytes.in", "must be in list "+formatBytesList(n.inVals), + val, protoreflect.ValueOfBytes(bytesVal)) + } + + // not_in + if len(n.notInVals) > 0 && slices.ContainsFunc(n.notInVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.notInDesc, + "bytes.not_in", "must not be in list "+formatBytesList(n.notInVals), + val, protoreflect.ValueOfBytes(bytesVal)) + } + + // well-known format constraints (ip, ipv4, ipv6, uuid) + if n.wellKnown != nil { + return n.evaluateWellKnown(bytesVal, val) + } + + return nil +} + +func (n nativeBytesEval) evaluateWellKnown(bytesVal []byte, val protoreflect.Value) error { + size := len(bytesVal) + wellKnown := n.wellKnown + + if size == 0 { + return n.newViolation(bytesDescs.ruleDesc, wellKnown.desc, + wellKnown.emptyRuleID, wellKnown.emptyMsg, + val, protoreflect.ValueOfBool(true)) + } + + if slices.Contains(wellKnown.validSizes, size) { + return nil + } + + return n.newViolation(bytesDescs.ruleDesc, wellKnown.desc, + wellKnown.ruleID, wellKnown.mainMsg, + val, protoreflect.ValueOfBool(true)) +} + +func (n nativeBytesEval) Tautology() bool { + return false +} + +var _ evaluator = nativeBytesEval{} + +// tryBuildNativeBytesRules attempts to build a native Go evaluator for +// bytes rules. Returns nil if the rules can't be handled natively. +func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { + if rules == nil { + return nil + } + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + // Detect well-known format constraint (ip, ipv4, ipv6, uuid). + // Check both presence and value — setting ip=false means no check. + var wellKnown *bytesWellKnown + switch { + case rules.GetIp(): + wellKnown = &bytesWellKnownIP + hasRule = true + case rules.GetIpv4(): + wellKnown = &bytesWellKnownIPv4 + hasRule = true + case rules.GetIpv6(): + wellKnown = &bytesWellKnownIPv6 + hasRule = true + case rules.GetUuid(): + wellKnown = &bytesWellKnownUUID + hasRule = true + } + + var constVal []byte + var hasConst bool + if rules.HasConst() { + constVal = rules.GetConst() + hasConst = true + hasRule = true + } + + var exactLen *uint64 + if rules.HasLen() { + exactLen = ptr(rules.GetLen()) + hasRule = true + } + + var minLen *uint64 + if rules.HasMinLen() { + minLen = ptr(rules.GetMinLen()) + hasRule = true + } + + var maxLen *uint64 + if rules.HasMaxLen() { + maxLen = ptr(rules.GetMaxLen()) + hasRule = true + } + + var compiledPattern *regexp.Regexp + var patternStr string + if rules.HasPattern() { + patternStr = rules.GetPattern() + var err error + compiledPattern, err = regexp.Compile(patternStr) + if err != nil { + return nil // bail to CEL + } + hasRule = true + } + + var prefix []byte + var hasPrefix bool + if rules.HasPrefix() { + prefix = rules.GetPrefix() + hasPrefix = true + hasRule = true + } + + var suffix []byte + var hasSuffix bool + if rules.HasSuffix() { + suffix = rules.GetSuffix() + hasSuffix = true + hasRule = true + } + + var contains []byte + var hasContains bool + if rules.HasContains() { + contains = rules.GetContains() + hasContains = true + hasRule = true + } + + var inVals [][]byte + if inVals = rules.GetIn(); len(inVals) > 0 { + hasRule = true + } + + var notInVals [][]byte + if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeBytesEval{ + base: base, + constVal: constVal, + hasConst: hasConst, + exactLen: exactLen, + minLen: minLen, + maxLen: maxLen, + pattern: compiledPattern, + patternStr: patternStr, + prefix: prefix, + hasPrefix: hasPrefix, + suffix: suffix, + hasSuffix: hasSuffix, + contains: contains, + hasContains: hasContains, + inVals: inVals, + notInVals: notInVals, + wellKnown: wellKnown, + } +} + +// formatBytesList formats a [][]byte to match CEL's list formatting. +func formatBytesList(vals [][]byte) string { + parts := make([]string, len(vals)) + for i, v := range vals { + // this is what CEL does for a byte slice; displays it as a string + parts[i] = string(v) + } + return "[" + strings.Join(parts, ", ") + "]" +} diff --git a/native_bytes_test.go b/native_bytes_test.go new file mode 100644 index 00000000..00315013 --- /dev/null +++ b/native_bytes_test.go @@ -0,0 +1,297 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + examplev1 "buf.build/go/protovalidate/internal/gen/tests/example/v1" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +func TestNativeBytes(t *testing.T) { + msg := examplev1.BenchTestBytes_builder{ + B1: []byte{'\x32', '\x33'}, + B: []byte{'\x03', '\x04'}, + }.Build() + // with PV off + t.Setenv("PV_NATIVE_RULES", "false") + val, err := New(WithMessages(msg), WithDisableLazy()) + if err != nil { + t.Fatalf("native off: expected no error, got %v", err) + } + err = val.Validate(msg) + if err == nil { + t.Errorf("native off: expected error, got nil") + } else { + x := err.Error() + if diff := cmp.Diff(x, `validation errors: + - b1: must not be in list [23, 45, 67] + - b: must be in list [23, 45, 67]`); diff != "" { + t.Error("native off, difference in error: " + diff) + } + } + + // with PV on + t.Setenv("PV_NATIVE_RULES", "true") + val, err = New(WithMessages(msg), WithDisableLazy()) + if err != nil { + t.Fatalf("native on: expected no error, got %v", err) + } + err = val.Validate(msg) + if err == nil { + t.Errorf("native on: expected error, got nil") + } else { + x := err.Error() + if diff := cmp.Diff(x, `validation errors: + - b1: must not be in list [23, 45, 67] + - b: must be in list [23, 45, 67]`); diff != "" { + t.Error("native on, difference in error: " + diff) + } + } +} +func TestNativeBytesConst(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Const: []byte{0x01, 0x02}}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x01, 0x02}), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x03}), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, "bytes.const", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "must be 0102", valErr.Violations[0].Proto.GetMessage()) +} + +func TestNativeBytesLen(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Len: proto.Uint64(4)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1, 2, 3, 4}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1, 2, 3}), &validationConfig{})) +} + +func TestNativeBytesMinLen(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{MinLen: proto.Uint64(2)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1, 2}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1}), &validationConfig{})) +} + +func TestNativeBytesMaxLen(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{MaxLen: proto.Uint64(3)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1, 2, 3}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1, 2, 3, 4}), &validationConfig{})) +} + +func TestNativeBytesPattern(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Pattern: proto.String("^[a-zA-Z0-9]+$")}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte("abc123")), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte("abc 123")), &validationConfig{})) +} + +func TestNativeBytesPrefix(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Prefix: []byte{0x01, 0x02}}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x01, 0x02, 0x03}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x03, 0x02, 0x01}), &validationConfig{})) +} + +func TestNativeBytesSuffix(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Suffix: []byte{0x03}}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x01, 0x02, 0x03}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x03, 0x02, 0x01}), &validationConfig{})) +} + +func TestNativeBytesContains(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Contains: []byte{0x02}}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x01, 0x02, 0x03}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x01, 0x03}), &validationConfig{})) +} + +func TestNativeBytesIn(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{ + In: [][]byte{{0x01}, {0x02}}, + }.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x01}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x03}), &validationConfig{})) +} + +func TestNativeBytesNotIn(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{ + NotIn: [][]byte{{0x00}}, + }.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x01}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{0x00}), &validationConfig{})) +} + +func TestNativeBytesIP(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Ip: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + + // valid IPv4 (4 bytes) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{127, 0, 0, 1}), &validationConfig{})) + // valid IPv6 (16 bytes) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes(make([]byte, 16)), &validationConfig{})) + + // empty → empty-specific message + err := eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{}), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "bytes.ip_empty", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "value is empty, which is not a valid IP address", valErr.Violations[0].Proto.GetMessage()) + + // wrong length → main message + err = eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1, 2, 3}), &validationConfig{}) + require.Error(t, err) + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "bytes.ip", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "must be a valid IP address", valErr.Violations[0].Proto.GetMessage()) +} + +func TestNativeBytesIPv4(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Ipv4: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{10, 0, 0, 1}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes(make([]byte, 16)), &validationConfig{})) +} + +func TestNativeBytesIPv6(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Ipv6: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes(make([]byte, 16)), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{}), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1, 2, 3, 4}), &validationConfig{})) +} + +func TestNativeBytesUUID(t *testing.T) { + t.Parallel() + eval := buildNativeBytes(t, validate.BytesRules_builder{Uuid: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfBytes(make([]byte, 16)), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{}), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "bytes.uuid_empty", valErr.Violations[0].Proto.GetRuleId()) + + err = eval.Evaluate(nil, protoreflect.ValueOfBytes([]byte{1, 2, 3}), &validationConfig{}) + require.Error(t, err) + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "bytes.uuid", valErr.Violations[0].Proto.GetRuleId()) +} + +func TestTryBuildNativeBytesRules_ReturnsNil(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.BytesRules + }{ + {"nil_rules", nil}, + {"empty_rules", validate.BytesRules_builder{}.Build()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Nil(t, tryBuildNativeBytesRules(base{}, tt.rules)) + }) + } +} + +func TestNativeBytes_EndToEnd(t *testing.T) { + t.Setenv("PV_NATIVE_RULES", "true") + + msgType := newDynamicMessageType(t, "test.native", "BytesMsg", &descriptorpb.FieldDescriptorProto{ + Name: proto.String("value"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_BYTES.Enum(), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Options: fieldOpts(validate.FieldRules_builder{ + Bytes: validate.BytesRules_builder{MinLen: proto.Uint64(2)}.Build(), + }.Build()), + }) + + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) + require.NoError(t, err) + + passing := dynamicpb.NewMessage(msgType.Descriptor()) + passing.Set(msgType.Descriptor().Fields().ByName("value"), protoreflect.ValueOfBytes([]byte{0x01, 0x02})) + require.NoError(t, validator.Validate(passing)) + + failing := dynamicpb.NewMessage(msgType.Descriptor()) + failing.Set(msgType.Descriptor().Fields().ByName("value"), protoreflect.ValueOfBytes([]byte{0x01})) + err = validator.Validate(failing) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, "bytes.min_len", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "must be at least 2 bytes", valErr.Violations[0].Proto.GetMessage()) +} + +func buildNativeBytes(t testing.TB, rules *validate.BytesRules) evaluator { + t.Helper() + fdesc := newFieldDescriptor(t, descriptorpb.FieldDescriptorProto_TYPE_BYTES, + descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum()) + b := base{ + Descriptor: fdesc, + FieldPathElement: fieldPathElement(fdesc), + } + return tryBuildNativeBytesRules(b, rules) +} diff --git a/native_enum.go b/native_enum.go new file mode 100644 index 00000000..2e58975a --- /dev/null +++ b/native_enum.go @@ -0,0 +1,111 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "fmt" + "slices" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/reflect/protoreflect" +) + +//nolint:gochecknoglobals +var ( + enumConstDesc = (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const") + enumInDesc = (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("in") + enumNotInDesc = (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("not_in") +) + +// nativeEnumEval is a native Go evaluator for enum const/in/not_in rules. +type nativeEnumEval struct { + base + constVal *int32 + inVals []int32 + notInVals []int32 +} + +func (n nativeEnumEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + enumVal := int32(val.Enum()) + // const + if n.constVal != nil && enumVal != *n.constVal { + return n.newViolation(enumRuleDescriptor, enumConstDesc, + "enum.const", fmt.Sprintf("must equal %d", *n.constVal), + val, protoreflect.ValueOfInt32(*n.constVal)) + } + + // in + if len(n.inVals) > 0 && !slices.Contains(n.inVals, enumVal) { + return n.newViolation(enumRuleDescriptor, enumInDesc, + "enum.in", "must be in list "+formatList(n.inVals), + val, protoreflect.ValueOfInt32(enumVal)) + } + + // not_in + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, enumVal) { + return n.newViolation(enumRuleDescriptor, enumNotInDesc, + "enum.not_in", "must not be in list "+formatList(n.notInVals), + val, protoreflect.ValueOfInt32(enumVal)) + } + + return nil +} + +func (n nativeEnumEval) Tautology() bool { + return false +} + +var _ evaluator = nativeEnumEval{} + +// tryBuildNativeEnumRules attempts to build a native Go evaluator for +// enum const/in/not_in rules. Returns nil if the rules can't be handled +// natively. Note: defined_only is handled separately in enum.go. +func tryBuildNativeEnumRules(base base, rules *validate.EnumRules) evaluator { + if rules == nil { + return nil + } + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + var constVal *int32 + if rules.HasConst() { + constVal = ptr(rules.GetConst()) + hasRule = true + } + + var inVals []int32 + if inVals = rules.GetIn(); len(inVals) > 0 { + hasRule = true + } + + var notInVals []int32 + if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeEnumEval{ + base: base, + constVal: constVal, + inVals: inVals, + notInVals: notInVals, + } +} diff --git a/native_enum_test.go b/native_enum_test.go new file mode 100644 index 00000000..d5fc897d --- /dev/null +++ b/native_enum_test.go @@ -0,0 +1,152 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +func TestNativeEnumConst(t *testing.T) { + t.Parallel() + eval := buildNativeEnum(t, validate.EnumRules_builder{Const: proto.Int32(1)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfEnum(1), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfEnum(2), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, "enum.const", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "must equal 1", valErr.Violations[0].Proto.GetMessage()) +} + +func TestNativeEnumIn(t *testing.T) { + t.Parallel() + eval := buildNativeEnum(t, validate.EnumRules_builder{In: []int32{1, 2}}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfEnum(1), &validationConfig{})) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfEnum(2), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfEnum(3), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "enum.in", valErr.Violations[0].Proto.GetRuleId()) +} + +func TestNativeEnumNotIn(t *testing.T) { + t.Parallel() + eval := buildNativeEnum(t, validate.EnumRules_builder{NotIn: []int32{0}}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfEnum(1), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfEnum(0), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "enum.not_in", valErr.Violations[0].Proto.GetRuleId()) +} + +func TestTryBuildNativeEnumRules_ReturnsNil(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.EnumRules + }{ + {"nil_rules", nil}, + {"empty_rules", validate.EnumRules_builder{}.Build()}, + {"defined_only_only", validate.EnumRules_builder{DefinedOnly: proto.Bool(true)}.Build()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Nil(t, tryBuildNativeEnumRules(base{}, tt.rules)) + }) + } +} + +func TestNativeEnumTautology(t *testing.T) { + t.Parallel() + eval := buildNativeEnum(t, validate.EnumRules_builder{Const: proto.Int32(1)}.Build()) + require.NotNil(t, eval) + assert.False(t, eval.Tautology()) +} + +func buildNativeEnum(t testing.TB, rules *validate.EnumRules) evaluator { + t.Helper() + fdesc := newEnumFieldDescriptor(t) + b := base{ + Descriptor: fdesc, + FieldPathElement: fieldPathElement(fdesc), + } + return tryBuildNativeEnumRules(b, rules) +} + +// newEnumFieldDescriptor creates a minimal enum field descriptor for testing. +func newEnumFieldDescriptor(t testing.TB) protoreflect.FieldDescriptor { + t.Helper() + fileProto := &descriptorpb.FileDescriptorProto{ + Name: proto.String("test_enum.proto"), + Package: proto.String("test"), + EnumType: []*descriptorpb.EnumDescriptorProto{{ + Name: proto.String("TestEnum"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + {Name: proto.String("UNSPECIFIED"), Number: proto.Int32(0)}, + {Name: proto.String("ONE"), Number: proto.Int32(1)}, + {Name: proto.String("TWO"), Number: proto.Int32(2)}, + }, + }}, + MessageType: []*descriptorpb.DescriptorProto{{ + Name: proto.String("Msg"), + Field: []*descriptorpb.FieldDescriptorProto{{ + Name: proto.String("val"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_ENUM.Enum(), + TypeName: proto.String(".test.TestEnum"), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + }}, + }}, + Syntax: proto.String("proto3"), + } + file, err := protodesc.NewFile(fileProto, nil) + require.NoError(t, err) + return file.Messages().Get(0).Fields().Get(0) +} + +// newRegistryWithValidateProto creates a file registry containing the +// validate proto needed for field options. +func newRegistryWithValidateProto(t testing.TB) *protoregistry.Files { + t.Helper() + registry := new(protoregistry.Files) + require.NoError(t, registry.RegisterFile(validate.File_buf_validate_validate_proto)) + require.NoError(t, registry.RegisterFile(wrapperspb.File_google_protobuf_wrappers_proto)) + return registry +} diff --git a/native_map.go b/native_map.go new file mode 100644 index 00000000..2d541dbb --- /dev/null +++ b/native_map.go @@ -0,0 +1,101 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "fmt" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/reflect/protoreflect" +) + +//nolint:gochecknoglobals +var ( + mapMinPairsDesc = (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("min_pairs") + mapMaxPairsDesc = (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_pairs") +) + +// nativeMapEval is a native Go evaluator for map-level rules +// (min_pairs, max_pairs). Key/value rules are handled separately +// by the kvPairs evaluator in map.go. +type nativeMapEval struct { + base + minPairs *uint64 + maxPairs *uint64 +} + +func (n nativeMapEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + size := uint64(val.Map().Len()) //nolint:gosec + + // min_pairs + if n.minPairs != nil && size < *n.minPairs { + return n.newViolation(mapFieldRulesDesc, mapMinPairsDesc, + "map.min_pairs", + fmt.Sprintf("map must be at least %d entries", *n.minPairs), + val, protoreflect.ValueOfUint64(*n.minPairs)) + } + + // max_pairs + if n.maxPairs != nil && size > *n.maxPairs { + return n.newViolation(mapFieldRulesDesc, mapMaxPairsDesc, + "map.max_pairs", + fmt.Sprintf("map must be at most %d entries", *n.maxPairs), + val, protoreflect.ValueOfUint64(*n.maxPairs)) + } + + return nil +} + +func (n nativeMapEval) Tautology() bool { + return false +} + +var _ evaluator = nativeMapEval{} + +// tryNativeMapRules attempts to build a native Go evaluator for +// map-level rules (min_pairs, max_pairs). +// Returns nil if the rules can't be handled natively. +func tryNativeMapRules(base base, rules *validate.MapRules) evaluator { + if rules == nil { + return nil + } + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + var minPairs *uint64 + if rules.HasMinPairs() { + minPairs = ptr(rules.GetMinPairs()) + hasRule = true + } + + var maxPairs *uint64 + if rules.HasMaxPairs() { + maxPairs = ptr(rules.GetMaxPairs()) + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeMapEval{ + base: base, + minPairs: minPairs, + maxPairs: maxPairs, + } +} diff --git a/native_map_test.go b/native_map_test.go new file mode 100644 index 00000000..42dacec7 --- /dev/null +++ b/native_map_test.go @@ -0,0 +1,204 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +func TestNativeMapMinPairs(t *testing.T) { + t.Parallel() + eval := buildNativeMap(t, validate.MapRules_builder{MinPairs: proto.Uint64(2)}.Build()) + require.NotNil(t, eval) + + // 2 entries passes + m := newStringStringMap(t, map[string]string{"a": "1", "b": "2"}) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfMap(m), &validationConfig{})) + + // 1 entry fails + m = newStringStringMap(t, map[string]string{"a": "1"}) + err := eval.Evaluate(nil, protoreflect.ValueOfMap(m), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, "map.min_pairs", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "map must be at least 2 entries", valErr.Violations[0].Proto.GetMessage()) +} + +func TestNativeMapMaxPairs(t *testing.T) { + t.Parallel() + eval := buildNativeMap(t, validate.MapRules_builder{MaxPairs: proto.Uint64(2)}.Build()) + require.NotNil(t, eval) + + // 2 entries passes + m := newStringStringMap(t, map[string]string{"a": "1", "b": "2"}) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfMap(m), &validationConfig{})) + + // 3 entries fails + m = newStringStringMap(t, map[string]string{"a": "1", "b": "2", "c": "3"}) + err := eval.Evaluate(nil, protoreflect.ValueOfMap(m), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "map.max_pairs", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "map must be at most 2 entries", valErr.Violations[0].Proto.GetMessage()) +} + +func TestTryNativeMapRules_ReturnsNil(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.MapRules + }{ + {"nil_rules", nil}, + {"empty_rules", validate.MapRules_builder{}.Build()}, + {"keys_only", validate.MapRules_builder{ + Keys: validate.FieldRules_builder{ + String: validate.StringRules_builder{MinLen: proto.Uint64(1)}.Build(), + }.Build(), + }.Build()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Nil(t, tryNativeMapRules(base{}, tt.rules)) + }) + } +} + +func TestNativeMapTautology(t *testing.T) { + t.Parallel() + eval := buildNativeMap(t, validate.MapRules_builder{MinPairs: proto.Uint64(1)}.Build()) + require.NotNil(t, eval) + assert.False(t, eval.Tautology()) +} + +// --- Helpers --- + +func buildNativeMap(t testing.TB, rules *validate.MapRules) evaluator { + t.Helper() + fdesc := newMapFieldDescriptor(t) + b := base{ + Descriptor: fdesc, + FieldPathElement: fieldPathElement(fdesc), + } + return tryNativeMapRules(b, rules) +} + +func newMapFieldDescriptor(t testing.TB) protoreflect.FieldDescriptor { + t.Helper() + msgType := newDynamicMapMessageType(t, "test.map", "MapTestMsg", + descriptorpb.FieldDescriptorProto_TYPE_STRING, + descriptorpb.FieldDescriptorProto_TYPE_STRING, + nil, + ) + return msgType.Descriptor().Fields().ByName("entries") +} + +func newStringStringMap(t testing.TB, entries map[string]string) protoreflect.Map { + t.Helper() + msgType := newDynamicMapMessageType(t, "test.map", "MapHelper", + descriptorpb.FieldDescriptorProto_TYPE_STRING, + descriptorpb.FieldDescriptorProto_TYPE_STRING, + nil, + ) + msg := dynamicpb.NewMessage(msgType.Descriptor()) + fd := msgType.Descriptor().Fields().ByName("entries") + mapField := msg.NewField(fd) + for k, v := range entries { + mapField.Map().Set( + protoreflect.ValueOfString(k).MapKey(), + protoreflect.ValueOfString(v), + ) + } + msg.Set(fd, mapField) + return msg.Get(fd).Map() +} + +// newDynamicMapMessageType creates a dynamic message type with a map field. +func newDynamicMapMessageType( + t testing.TB, + pkg, name string, + keyType, valueType descriptorpb.FieldDescriptorProto_Type, + rules *validate.FieldRules, +) protoreflect.MessageType { + t.Helper() + + mapEntryName := "EntriesEntry" + field := &descriptorpb.FieldDescriptorProto{ + Name: proto.String("entries"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum(), + TypeName: proto.String("." + pkg + "." + name + "." + mapEntryName), + Label: descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum(), + } + if rules != nil { + field.Options = fieldOpts(rules) + } + + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String(pkg + "." + name + ".proto"), + Package: proto.String(pkg), + Syntax: proto.String("proto3"), + Dependency: []string{ + "buf/validate/validate.proto", + }, + MessageType: []*descriptorpb.DescriptorProto{{ + Name: proto.String(name), + Field: []*descriptorpb.FieldDescriptorProto{field}, + NestedType: []*descriptorpb.DescriptorProto{{ + Name: proto.String(mapEntryName), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("key"), + Number: proto.Int32(1), + Type: keyType.Enum(), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + }, + { + Name: proto.String("value"), + Number: proto.Int32(2), + Type: valueType.Enum(), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + }, + }, + Options: &descriptorpb.MessageOptions{ + MapEntry: proto.Bool(true), + }, + }}, + }}, + } + + registry := newRegistryWithValidateProto(t) + fd, err := protodesc.FileOptions{}.New(file, registry) + require.NoError(t, err) + + desc := fd.Messages().ByName(protoreflect.Name(name)) + require.NotNil(t, desc) + + return dynamicpb.NewMessageType(desc) +} diff --git a/native_numeric.go b/native_numeric.go new file mode 100644 index 00000000..a69546fc --- /dev/null +++ b/native_numeric.go @@ -0,0 +1,576 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "fmt" + "math" + "slices" + "strings" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// numericValue is the set of Go types that back protobuf numeric field kinds. +type numericValue interface { + ~int32 | ~int64 | ~uint32 | ~uint64 | ~float32 | ~float64 +} + +// numericRules is satisfied by all generated numeric rules types +// (Int32Rules, Int64Rules, UInt32Rules, etc.). +// +//nolint:interfacebloat +type numericRules[T numericValue] interface { + HasGt() bool + GetGt() T + HasGte() bool + GetGte() T + HasLt() bool + GetLt() T + HasLte() bool + GetLte() T + HasConst() bool + GetConst() T + GetIn() []T + GetNotIn() []T + ProtoReflect() protoreflect.Message +} + +// numericDescriptors bundles the field descriptors for a single numeric +// rules type (e.g., Int32Rules). Used to construct violation rule paths. +type numericDescriptors struct { + ruleDesc protoreflect.FieldDescriptor // FieldRules.{type} (e.g., "int32") + gtDesc protoreflect.FieldDescriptor + gteDesc protoreflect.FieldDescriptor + ltDesc protoreflect.FieldDescriptor + lteDesc protoreflect.FieldDescriptor + constDesc protoreflect.FieldDescriptor + inDesc protoreflect.FieldDescriptor + notInDesc protoreflect.FieldDescriptor + finiteDesc protoreflect.FieldDescriptor +} + +func makeNumericDescriptors( + fieldName string, + rulesMsg protoreflect.ProtoMessage, +) numericDescriptors { + rulesDesc := rulesMsg.ProtoReflect().Descriptor() + var finiteDesc protoreflect.FieldDescriptor + if rulesDesc.Name() == "FloatRules" || rulesDesc.Name() == "DoubleRules" { + finiteDesc = rulesDesc.Fields().ByName("finite") + } + return numericDescriptors{ + ruleDesc: fieldRulesDesc.Fields().ByName(protoreflect.Name(fieldName)), + gtDesc: rulesDesc.Fields().ByName("gt"), + gteDesc: rulesDesc.Fields().ByName("gte"), + ltDesc: rulesDesc.Fields().ByName("lt"), + lteDesc: rulesDesc.Fields().ByName("lte"), + constDesc: rulesDesc.Fields().ByName("const"), + inDesc: rulesDesc.Fields().ByName("in"), + notInDesc: rulesDesc.Fields().ByName("not_in"), + finiteDesc: finiteDesc, + } +} + +// numericTypeConfig holds all type-specific operations and metadata +// for a single proto numeric kind. +type numericTypeConfig[T numericValue] struct { + typeName string // proto rule prefix: "int32", "sint32", "float", etc. + descs numericDescriptors // descriptor bundle for rule path construction + extractVal func(protoreflect.Value) T // val.Int/Uint/Float + cast + makeRuleVal func(T) protoreflect.Value // ValueOfInt32, ValueOfFloat32, etc. + nanFailsRange bool // true only for float32, float64 +} + +//nolint:gochecknoglobals +var ( + int32Descs = makeNumericDescriptors("int32", (*validate.Int32Rules)(nil)) + sint32Descs = makeNumericDescriptors("sint32", (*validate.SInt32Rules)(nil)) + sfixed32Descs = makeNumericDescriptors("sfixed32", (*validate.SFixed32Rules)(nil)) + int64Descs = makeNumericDescriptors("int64", (*validate.Int64Rules)(nil)) + sint64Descs = makeNumericDescriptors("sint64", (*validate.SInt64Rules)(nil)) + sfixed64Descs = makeNumericDescriptors("sfixed64", (*validate.SFixed64Rules)(nil)) + uint32Descs = makeNumericDescriptors("uint32", (*validate.UInt32Rules)(nil)) + fixed32Descs = makeNumericDescriptors("fixed32", (*validate.Fixed32Rules)(nil)) + uint64Descs = makeNumericDescriptors("uint64", (*validate.UInt64Rules)(nil)) + fixed64Descs = makeNumericDescriptors("fixed64", (*validate.Fixed64Rules)(nil)) + floatDescs = makeNumericDescriptors("float", (*validate.FloatRules)(nil)) + doubleDescs = makeNumericDescriptors("double", (*validate.DoubleRules)(nil)) +) + +//nolint:gochecknoglobals +var ( + int32Config = numericTypeConfig[int32]{ + typeName: "int32", + descs: int32Descs, + extractVal: func(v protoreflect.Value) int32 { return int32(v.Int()) }, + makeRuleVal: protoreflect.ValueOfInt32, + } + sint32Config = numericTypeConfig[int32]{ + typeName: "sint32", + descs: sint32Descs, + extractVal: func(v protoreflect.Value) int32 { return int32(v.Int()) }, + makeRuleVal: protoreflect.ValueOfInt32, + } + sfixed32Config = numericTypeConfig[int32]{ + typeName: "sfixed32", + descs: sfixed32Descs, + extractVal: func(v protoreflect.Value) int32 { return int32(v.Int()) }, + makeRuleVal: protoreflect.ValueOfInt32, + } + int64Config = numericTypeConfig[int64]{ + typeName: "int64", + descs: int64Descs, + extractVal: func(v protoreflect.Value) int64 { return v.Int() }, + makeRuleVal: protoreflect.ValueOfInt64, + } + sint64Config = numericTypeConfig[int64]{ + typeName: "sint64", + descs: sint64Descs, + extractVal: func(v protoreflect.Value) int64 { return v.Int() }, + makeRuleVal: protoreflect.ValueOfInt64, + } + sfixed64Config = numericTypeConfig[int64]{ + typeName: "sfixed64", + descs: sfixed64Descs, + extractVal: func(v protoreflect.Value) int64 { return v.Int() }, + makeRuleVal: protoreflect.ValueOfInt64, + } + uint32Config = numericTypeConfig[uint32]{ + typeName: "uint32", + descs: uint32Descs, + extractVal: func(v protoreflect.Value) uint32 { return uint32(v.Uint()) }, + makeRuleVal: protoreflect.ValueOfUint32, + } + fixed32Config = numericTypeConfig[uint32]{ + typeName: "fixed32", + descs: fixed32Descs, + extractVal: func(v protoreflect.Value) uint32 { return uint32(v.Uint()) }, + makeRuleVal: protoreflect.ValueOfUint32, + } + uint64Config = numericTypeConfig[uint64]{ + typeName: "uint64", + descs: uint64Descs, + extractVal: func(v protoreflect.Value) uint64 { return v.Uint() }, + makeRuleVal: protoreflect.ValueOfUint64, + } + fixed64Config = numericTypeConfig[uint64]{ + typeName: "fixed64", + descs: fixed64Descs, + extractVal: func(v protoreflect.Value) uint64 { return v.Uint() }, + makeRuleVal: protoreflect.ValueOfUint64, + } + floatConfig = numericTypeConfig[float32]{ + typeName: "float", + descs: floatDescs, + extractVal: func(v protoreflect.Value) float32 { return float32(v.Float()) }, + makeRuleVal: protoreflect.ValueOfFloat32, + nanFailsRange: true, + } + doubleConfig = numericTypeConfig[float64]{ + typeName: "double", + descs: doubleDescs, + extractVal: func(v protoreflect.Value) float64 { return v.Float() }, + makeRuleVal: protoreflect.ValueOfFloat64, + nanFailsRange: true, + } +) + +// lowerBound describes which lower bound constraint is active. +type lowerBound int + +const ( + lowerBoundNone lowerBound = iota + // lowerBoundGte is an inclusive lower bound (>=). + lowerBoundGte + // lowerBoundGt is an exclusive lower bound (>). + lowerBoundGt +) + +// upperBound describes which upper bound constraint is active. +type upperBound int + +const ( + upperBoundNone upperBound = iota + upperBoundLt + upperBoundLte +) + +// nativeNumericCompare is a native Go evaluator for numeric gt/gte/lt/lte/ +// const/in/not_in rules. It replaces CEL evaluation with direct Go comparisons. +type nativeNumericCompare[T numericValue] struct { + base + config numericTypeConfig[T] + lo T // lower bound value (gt or gte threshold) + lower lowerBound // gt (exclusive) or gte (inclusive) + hi T // upper bound value (lt or lte threshold) + upper upperBound // none, lt, or lte + constVal *T // constant value for comparison + inVals []T // slice of values for IN comparison + notInVals []T // slice of values for NOT_IN comparison + finite bool // true if the value is finite (not NaN or Infinity) +} + +// belowLo reports whether v violates the lower bound. +func (n nativeNumericCompare[T]) belowLo(v T) bool { + if n.lower == lowerBoundGt { + return v <= n.lo + } + return v < n.lo +} + +// aboveHi reports whether v violates the upper bound. +func (n nativeNumericCompare[T]) aboveHi(v T) bool { + if n.upper == upperBoundLt { + return v >= n.hi + } + return v > n.hi +} + +// isNormalRange reports whether lo and hi form a normal (non-exclusive) range. +func (n nativeNumericCompare[T]) isNormalRange() bool { + return n.hi >= n.lo +} + +func (n nativeNumericCompare[T]) loDesc() protoreflect.FieldDescriptor { + if n.lower == lowerBoundGt { + return n.config.descs.gtDesc + } + return n.config.descs.gteDesc +} + +func (n nativeNumericCompare[T]) hiDesc() protoreflect.FieldDescriptor { + if n.upper == upperBoundLt { + return n.config.descs.ltDesc + } + return n.config.descs.lteDesc +} + +func (n nativeNumericCompare[T]) gtRulePrefix() string { + if n.lower == lowerBoundGt { + return n.config.typeName + ".gt" + } + return n.config.typeName + ".gte" +} + +func (n nativeNumericCompare[T]) ltRulePrefix() string { + if n.upper == upperBoundLt { + return n.config.typeName + ".lt" + } + return n.config.typeName + ".lte" +} + +func (n nativeNumericCompare[T]) gtltRule() string { + if n.lower != lowerBoundNone { + prefix := n.gtRulePrefix() + switch n.upper { + case upperBoundLt: + prefix += "_lt" + if !n.isNormalRange() { + prefix += "_exclusive" + } + case upperBoundLte: + prefix += "_lte" + if !n.isNormalRange() { + prefix += "_exclusive" + } + } + return prefix + } + return n.ltRulePrefix() +} + +func (n nativeNumericCompare[T]) loMessage() string { + if n.lower == lowerBoundGt { + return fmt.Sprintf("greater than %v", n.lo) + } + return fmt.Sprintf("greater than or equal to %v", n.lo) +} + +func (n nativeNumericCompare[T]) hiMessage() string { + if n.upper == upperBoundLt { + return fmt.Sprintf("less than %v", n.hi) + } + return fmt.Sprintf("less than or equal to %v", n.hi) +} + +func (n nativeNumericCompare[T]) conjunction() string { + if n.isNormalRange() { + return "and" + } + return "or" +} + +func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + valT := n.config.extractVal(val) + // const support + if n.constVal != nil && valT != *n.constVal { + return n.newViolation(n.config.descs.ruleDesc, n.config.descs.constDesc, + n.config.typeName+".const", + fmt.Sprintf("must equal %v", *n.constVal), + val, n.config.makeRuleVal(*n.constVal)) + } + + // in support + if len(n.inVals) > 0 && !slices.Contains(n.inVals, valT) { + return n.newViolation(n.config.descs.ruleDesc, n.config.descs.inDesc, + n.config.typeName+".in", + "must be in list "+formatList(n.inVals), + val, n.config.makeRuleVal(valT)) + } + + // not in support + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, valT) { + return n.newViolation(n.config.descs.ruleDesc, n.config.descs.notInDesc, + n.config.typeName+".not_in", + "must not be in list "+formatList(n.notInVals), + val, n.config.makeRuleVal(valT)) + } + + // finite support + if n.finite && (math.IsNaN(float64(valT)) || math.IsInf(float64(valT), 0)) { + return n.newViolation(n.config.descs.ruleDesc, n.config.descs.finiteDesc, + n.config.typeName+".finite", + "must be finite", + val, n.config.makeRuleVal(valT)) + } + + if n.lower == lowerBoundNone && n.upper == upperBoundNone { + return nil + } + // For float/double, NaN fails all range checks (matches CEL behavior). + isNaN := n.config.nanFailsRange && math.IsNaN(float64(valT)) + + switch { + case n.lower == lowerBoundNone: + if isNaN || n.aboveHi(valT) { + return n.newViolation(n.config.descs.ruleDesc, n.hiDesc(), + n.gtltRule(), "must be "+n.hiMessage(), + val, n.config.makeRuleVal(n.hi)) + } + case n.upper == upperBoundNone: + if isNaN || n.belowLo(valT) { + return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), + n.gtltRule(), "must be "+n.loMessage(), + val, n.config.makeRuleVal(n.lo)) + } + default: + var failure bool + if n.isNormalRange() { + failure = isNaN || n.aboveHi(valT) || n.belowLo(valT) + } else { + failure = isNaN || (n.aboveHi(valT) && n.belowLo(valT)) + } + if failure { + return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), + n.gtltRule(), + fmt.Sprintf("must be %s %s %s", n.loMessage(), n.conjunction(), n.hiMessage()), + val, n.config.makeRuleVal(n.lo)) + } + } + return nil +} + +func (n nativeNumericCompare[T]) Tautology() bool { + return false +} + +var _ evaluator = nativeNumericCompare[int32]{} + +// tryBuildNativeNumericRules attempts to build a native Go evaluator for +// numeric rules. Returns nil if the rules can't be handled natively, +// including cases with unknown fields (custom predefined extensions). +func tryBuildNativeNumericRules[T numericValue, R numericRules[T]]( + base base, + rules R, + config numericTypeConfig[T], +) evaluator { + // Bail out if the rules message has unknown fields, which indicate + // custom predefined extensions that we can't handle natively. + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + var lowerValue T + lower := lowerBoundNone + switch { + case rules.HasGt(): + lower = lowerBoundGt + lowerValue = rules.GetGt() + hasRule = true + case rules.HasGte(): + lower = lowerBoundGte + lowerValue = rules.GetGte() + hasRule = true + } + + var upperValue T + upper := upperBoundNone + switch { + case rules.HasLt(): + upper = upperBoundLt + upperValue = rules.GetLt() + hasRule = true + case rules.HasLte(): + upper = upperBoundLte + upperValue = rules.GetLte() + hasRule = true + } + + var constVal *T + if rules.HasConst() { + constVal = ptr(rules.GetConst()) + hasRule = true + } + + var inVals []T + if inVals = rules.GetIn(); len(inVals) > 0 { + hasRule = true + } + + var notInVals []T + if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + hasRule = true + } + + type finiteInterface interface { + HasFinite() bool + GetFinite() bool + } + + finite := false + if fi, ok := (any)(rules).(finiteInterface); ok && fi.HasFinite() { + finite = fi.GetFinite() + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeNumericCompare[T]{ + base: base, + config: config, + lo: lowerValue, + lower: lower, + hi: upperValue, + upper: upper, + constVal: constVal, + inVals: inVals, + notInVals: notInVals, + finite: finite, + } +} + +// Per-kind builder wrappers. Each handles nil check and type-specific +// concerns before delegating to the generic builder. + +func tryBuildNativeInt32Rules(base base, rules *validate.Int32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, int32Config) +} + +func tryBuildNativeSint32Rules(base base, rules *validate.SInt32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, sint32Config) +} + +func tryBuildNativeSfixed32Rules(base base, rules *validate.SFixed32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, sfixed32Config) +} + +func tryBuildNativeInt64Rules(base base, rules *validate.Int64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, int64Config) +} + +func tryBuildNativeSint64Rules(base base, rules *validate.SInt64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, sint64Config) +} + +func tryBuildNativeSfixed64Rules(base base, rules *validate.SFixed64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, sfixed64Config) +} + +func tryBuildNativeUint32Rules(base base, rules *validate.UInt32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, uint32Config) +} + +func tryBuildNativeFixed32Rules(base base, rules *validate.Fixed32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, fixed32Config) +} + +func tryBuildNativeUint64Rules(base base, rules *validate.UInt64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, uint64Config) +} + +func tryBuildNativeFixed64Rules(base base, rules *validate.Fixed64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, fixed64Config) +} + +func tryBuildNativeFloatRules(base base, rules *validate.FloatRules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, floatConfig) +} + +func tryBuildNativeDoubleRules(base base, rules *validate.DoubleRules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, doubleConfig) +} + +func ptr[T any](v T) *T { return &v } + +// formatList formats a slice as "list [val1, val2]" to match CEL message format. +func formatList[T any](vals []T) string { + parts := make([]string, len(vals)) + for i, v := range vals { + parts[i] = fmt.Sprintf("%v", v) + } + return "[" + strings.Join(parts, ", ") + "]" +} diff --git a/native_numeric_test.go b/native_numeric_test.go new file mode 100644 index 00000000..7572e76a --- /dev/null +++ b/native_numeric_test.go @@ -0,0 +1,544 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "math" + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +// numericTestCase defines a single constraint test case. +type numericTestCase[T numericValue] struct { + name string + pass []T + fail []T + ruleID string +} + +// runNumericCases runs a set of test cases against the given evaluator. +func runNumericCases[T numericValue]( + t *testing.T, + eval evaluator, + toValue func(T) protoreflect.Value, + cases []numericTestCase[T], +) { + t.Helper() + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + for _, v := range tt.pass { + err := eval.Evaluate(nil, toValue(v), &validationConfig{}) + require.NoError(t, err, "expected %v to pass", v) + } + for _, v := range tt.fail { + err := eval.Evaluate(nil, toValue(v), &validationConfig{}) + require.Error(t, err, "expected %v to fail", v) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, tt.ruleID, valErr.Violations[0].Proto.GetRuleId()) + } + }) + } +} + +// --- Int32 --- + +func TestNativeInt32Compare(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.Int32Rules + cases []numericTestCase[int32] + }{ + { + name: "gt_only", + rules: validate.Int32Rules_builder{Gt: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{6}, []int32{4, 5}, "int32.gt"}, + }, + }, + { + name: "gte_only", + rules: validate.Int32Rules_builder{Gte: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{5, 6}, []int32{4}, "int32.gte"}, + }, + }, + { + name: "lt_only", + rules: validate.Int32Rules_builder{Lt: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{4}, []int32{5, 6}, "int32.lt"}, + }, + }, + { + name: "lte_only", + rules: validate.Int32Rules_builder{Lte: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{4, 5}, []int32{6}, "int32.lte"}, + }, + }, + { + name: "gt_lt", + rules: validate.Int32Rules_builder{Gt: proto.Int32(0), Lt: proto.Int32(10)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{1, 9}, []int32{-1, 0, 10, 11}, "int32.gt_lt"}, + }, + }, + { + name: "gt_lt_exclusive", + rules: validate.Int32Rules_builder{Gt: proto.Int32(10), Lt: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{4, 11}, []int32{5, 6, 9, 10}, "int32.gt_lt_exclusive"}, + }, + }, + { + name: "gt_lte", + rules: validate.Int32Rules_builder{Gt: proto.Int32(0), Lte: proto.Int32(10)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{1, 9, 10}, []int32{-1, 0, 11}, "int32.gt_lte"}, + }, + }, + { + name: "gt_lte_exclusive", + rules: validate.Int32Rules_builder{Gt: proto.Int32(10), Lte: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{4, 5, 11}, []int32{6, 9, 10}, "int32.gt_lte_exclusive"}, + }, + }, + { + name: "gte_lt", + rules: validate.Int32Rules_builder{Gte: proto.Int32(0), Lt: proto.Int32(10)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{0, 1, 9}, []int32{-1, 10, 11}, "int32.gte_lt"}, + }, + }, + { + name: "gte_lt_exclusive", + rules: validate.Int32Rules_builder{Gte: proto.Int32(10), Lt: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{4, 10, 11}, []int32{5, 6, 9}, "int32.gte_lt_exclusive"}, + }, + }, + { + name: "gte_lte", + rules: validate.Int32Rules_builder{Gte: proto.Int32(0), Lte: proto.Int32(10)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{0, 1, 9, 10}, []int32{-1, 11}, "int32.gte_lte"}, + }, + }, + { + name: "gte_lte_exclusive", + rules: validate.Int32Rules_builder{Gte: proto.Int32(10), Lte: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{4, 5, 10, 11}, []int32{6, 9}, "int32.gte_lte_exclusive"}, + }, + }, + { + name: "gte_eq_lt", + rules: validate.Int32Rules_builder{Gte: proto.Int32(5), Lt: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"all_fail", nil, []int32{4, 5, 6}, "int32.gte_lt"}, + }, + }, + { + name: "gt_eq_lt", + rules: validate.Int32Rules_builder{Gt: proto.Int32(5), Lt: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"all_fail", nil, []int32{4, 5, 6}, "int32.gt_lt"}, + }, + }, + { + name: "gt_eq_lte", + rules: validate.Int32Rules_builder{Gt: proto.Int32(5), Lte: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"all_fail", nil, []int32{4, 5, 6}, "int32.gt_lte"}, + }, + }, + { + name: "gte_eq_lte", + rules: validate.Int32Rules_builder{Gte: proto.Int32(5), Lte: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{5}, []int32{4, 6}, "int32.gte_lte"}, + }, + }, + { + name: "const", + rules: validate.Int32Rules_builder{Const: proto.Int32(5)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{5}, []int32{4, 6}, "int32.const"}, + }, + }, + { + name: "const_with_gte", + rules: validate.Int32Rules_builder{Const: proto.Int32(5), Gte: proto.Int32(4)}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{5}, []int32{3, 4, 6}, "int32.const"}, + }, + }, + { + name: "in", + rules: validate.Int32Rules_builder{In: []int32{1, 3, 5}}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{1, 3, 5}, []int32{0, 2, 4, 6}, "int32.in"}, + }, + }, + { + name: "not_in", + rules: validate.Int32Rules_builder{NotIn: []int32{1, 3, 5}}.Build(), + cases: []numericTestCase[int32]{ + {"pass_fail", []int32{0, 2, 4, 6}, []int32{1, 3, 5}, "int32.not_in"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + eval := buildNativeNumeric(t, tt.rules, int32Config, descriptorpb.FieldDescriptorProto_TYPE_INT32) + require.NotNil(t, eval) + runNumericCases(t, eval, protoreflect.ValueOfInt32, tt.cases) + }) + } +} + +// --- Int64 --- + +func TestNativeInt64Compare(t *testing.T) { + t.Parallel() + + rules := validate.Int64Rules_builder{Gt: proto.Int64(0), Lt: proto.Int64(100)}.Build() + eval := buildNativeNumeric(t, rules, int64Config, descriptorpb.FieldDescriptorProto_TYPE_INT64) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfInt64, []numericTestCase[int64]{ + {"pass_fail", []int64{1, 50, 99}, []int64{-1, 0, 100, 101}, "int64.gt_lt"}, + }) +} + +// --- Uint32 --- + +func TestNativeUint32Compare(t *testing.T) { + t.Parallel() + + rules := validate.UInt32Rules_builder{Gte: proto.Uint32(5), Lte: proto.Uint32(10)}.Build() + eval := buildNativeNumeric(t, rules, uint32Config, descriptorpb.FieldDescriptorProto_TYPE_UINT32) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfUint32, []numericTestCase[uint32]{ + {"pass_fail", []uint32{5, 7, 10}, []uint32{4, 11}, "uint32.gte_lte"}, + }) +} + +// --- Uint64 --- + +func TestNativeUint64Compare(t *testing.T) { + t.Parallel() + + rules := validate.UInt64Rules_builder{Gt: proto.Uint64(0)}.Build() + eval := buildNativeNumeric(t, rules, uint64Config, descriptorpb.FieldDescriptorProto_TYPE_UINT64) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfUint64, []numericTestCase[uint64]{ + {"pass_fail", []uint64{1, 100}, []uint64{0}, "uint64.gt"}, + }) +} + +// --- Sint32 --- + +func TestNativeSint32Compare(t *testing.T) { + t.Parallel() + + rules := validate.SInt32Rules_builder{Gt: proto.Int32(-10), Lt: proto.Int32(10)}.Build() + eval := buildNativeNumeric(t, rules, sint32Config, descriptorpb.FieldDescriptorProto_TYPE_SINT32) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfInt32, []numericTestCase[int32]{ + {"pass_fail", []int32{-9, 0, 9}, []int32{-10, 10}, "sint32.gt_lt"}, + }) +} + +// --- Sint64 --- + +func TestNativeSint64Compare(t *testing.T) { + t.Parallel() + + rules := validate.SInt64Rules_builder{Gte: proto.Int64(0)}.Build() + eval := buildNativeNumeric(t, rules, sint64Config, descriptorpb.FieldDescriptorProto_TYPE_SINT64) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfInt64, []numericTestCase[int64]{ + {"pass_fail", []int64{0, 1, 100}, []int64{-1}, "sint64.gte"}, + }) +} + +// --- Fixed32 --- + +func TestNativeFixed32Compare(t *testing.T) { + t.Parallel() + + rules := validate.Fixed32Rules_builder{Lt: proto.Uint32(100)}.Build() + eval := buildNativeNumeric(t, rules, fixed32Config, descriptorpb.FieldDescriptorProto_TYPE_FIXED32) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfUint32, []numericTestCase[uint32]{ + {"pass_fail", []uint32{0, 99}, []uint32{100, 101}, "fixed32.lt"}, + }) +} + +// --- Fixed64 --- + +func TestNativeFixed64Compare(t *testing.T) { + t.Parallel() + + rules := validate.Fixed64Rules_builder{Lte: proto.Uint64(50)}.Build() + eval := buildNativeNumeric(t, rules, fixed64Config, descriptorpb.FieldDescriptorProto_TYPE_FIXED64) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfUint64, []numericTestCase[uint64]{ + {"pass_fail", []uint64{0, 50}, []uint64{51}, "fixed64.lte"}, + }) +} + +// --- Sfixed32 --- + +func TestNativeSfixed32Compare(t *testing.T) { + t.Parallel() + + rules := validate.SFixed32Rules_builder{Const: proto.Int32(42)}.Build() + eval := buildNativeNumeric(t, rules, sfixed32Config, descriptorpb.FieldDescriptorProto_TYPE_SFIXED32) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfInt32, []numericTestCase[int32]{ + {"pass_fail", []int32{42}, []int32{0, 41, 43}, "sfixed32.const"}, + }) +} + +// --- Sfixed64 --- + +func TestNativeSfixed64Compare(t *testing.T) { + t.Parallel() + + rules := validate.SFixed64Rules_builder{In: []int64{1, 2, 3}}.Build() + eval := buildNativeNumeric(t, rules, sfixed64Config, descriptorpb.FieldDescriptorProto_TYPE_SFIXED64) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfInt64, []numericTestCase[int64]{ + {"pass_fail", []int64{1, 2, 3}, []int64{0, 4}, "sfixed64.in"}, + }) +} + +// --- Float --- + +func TestNativeFloatCompare(t *testing.T) { + t.Parallel() + + rules := validate.FloatRules_builder{Gt: proto.Float32(0), Lt: proto.Float32(10)}.Build() + eval := buildNativeNumeric(t, rules, floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfFloat32, []numericTestCase[float32]{ + {"pass_fail", []float32{0.1, 5, 9.9}, []float32{-1, 0, 10, 11}, "float.gt_lt"}, + }) +} + +func TestNativeFloatFinite(t *testing.T) { + t.Parallel() + + rules := validate.FloatRules_builder{Finite: proto.Bool(true)}.Build() + eval := buildNativeNumeric(t, rules, floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfFloat32, []numericTestCase[float32]{ + {"pass_fail", []float32{0.1, 5, 9.9}, []float32{float32(math.NaN()), float32(math.Inf(1)), float32(math.Inf(-1))}, "float.finite"}, + }) +} + +func TestNativeDoubleFinite(t *testing.T) { + t.Parallel() + + rules := validate.DoubleRules_builder{Finite: proto.Bool(true)}.Build() + eval := buildNativeNumeric(t, rules, doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfFloat64, []numericTestCase[float64]{ + {"pass_fail", []float64{0.1, 5, 9.9}, []float64{math.NaN(), math.Inf(1), math.Inf(-1)}, "double.finite"}, + }) +} + +func TestNativeFloat_NaN(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.FloatRules + }{ + {"gt", validate.FloatRules_builder{Gt: proto.Float32(0)}.Build()}, + {"lt", validate.FloatRules_builder{Lt: proto.Float32(10)}.Build()}, + {"gte_lte", validate.FloatRules_builder{Gte: proto.Float32(0), Lte: proto.Float32(10)}.Build()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + eval := buildNativeNumeric(t, tt.rules, floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) + require.NotNil(t, eval) + nan := float32(math.NaN()) + err := eval.Evaluate(nil, protoreflect.ValueOfFloat32(nan), &validationConfig{}) + require.Error(t, err, "NaN should fail range checks") + }) + } +} + +func TestNativeFloat_FiniteDoesNotBailToCEL(t *testing.T) { + t.Parallel() + rules := validate.FloatRules_builder{Finite: proto.Bool(true), Gt: proto.Float32(0)}.Build() + eval := tryBuildNativeFloatRules(base{}, rules) + if eval == nil { + t.Error("finite rules should have native implementation") + } +} + +// --- Double --- + +func TestNativeDoubleCompare(t *testing.T) { + t.Parallel() + + rules := validate.DoubleRules_builder{Gte: proto.Float64(-1.5), Lte: proto.Float64(1.5)}.Build() + eval := buildNativeNumeric(t, rules, doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) + require.NotNil(t, eval) + + runNumericCases(t, eval, protoreflect.ValueOfFloat64, []numericTestCase[float64]{ + {"pass_fail", []float64{-1.5, 0, 1.5}, []float64{-2, 2}, "double.gte_lte"}, + }) +} + +func TestNativeDouble_NaN(t *testing.T) { + t.Parallel() + + rules := validate.DoubleRules_builder{Gt: proto.Float64(0)}.Build() + eval := buildNativeNumeric(t, rules, doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) + require.NotNil(t, eval) + + err := eval.Evaluate(nil, protoreflect.ValueOfFloat64(math.NaN()), &validationConfig{}) + require.Error(t, err, "NaN should fail range checks") +} + +// --- Common tests --- + +func TestNativeNumericCompare_FieldValue(t *testing.T) { + t.Parallel() + eval := buildNativeNumeric(t, + validate.Int32Rules_builder{Gt: proto.Int32(5)}.Build(), + int32Config, + descriptorpb.FieldDescriptorProto_TYPE_INT32, + ) + require.NotNil(t, eval) + val := protoreflect.ValueOfInt32(3) + err := eval.Evaluate(nil, val, &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, int64(3), valErr.Violations[0].FieldValue.Int()) +} + +func TestNativeNumericCompare_Tautology(t *testing.T) { + t.Parallel() + eval := buildNativeNumeric(t, + validate.Int32Rules_builder{Gt: proto.Int32(0)}.Build(), + int32Config, + descriptorpb.FieldDescriptorProto_TYPE_INT32, + ) + require.NotNil(t, eval) + assert.False(t, eval.Tautology()) +} + +func TestTryBuildNativeNumericRules_ReturnsNil(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fn func() evaluator + }{ + {"nil_int32", func() evaluator { return tryBuildNativeInt32Rules(base{}, nil) }}, + {"nil_int64", func() evaluator { return tryBuildNativeInt64Rules(base{}, nil) }}, + {"nil_uint32", func() evaluator { return tryBuildNativeUint32Rules(base{}, nil) }}, + {"nil_uint64", func() evaluator { return tryBuildNativeUint64Rules(base{}, nil) }}, + {"nil_float", func() evaluator { return tryBuildNativeFloatRules(base{}, nil) }}, + {"nil_double", func() evaluator { return tryBuildNativeDoubleRules(base{}, nil) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Nil(t, tt.fn()) + }) + } +} + +// --- Helpers --- + +// buildNativeNumeric constructs a nativeNumericCompare evaluator for testing. +func buildNativeNumeric[T numericValue, R numericRules[T]]( + t testing.TB, + rules R, + config numericTypeConfig[T], + fieldType descriptorpb.FieldDescriptorProto_Type, +) evaluator { + t.Helper() + fdesc := newFieldDescriptor(t, fieldType, + descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum()) + b := base{ + Descriptor: fdesc, + FieldPathElement: fieldPathElement(fdesc), + } + return tryBuildNativeNumericRules(b, rules, config) +} + +// newFieldDescriptor creates a minimal field descriptor for a given proto type. +func newFieldDescriptor(t testing.TB, fieldType descriptorpb.FieldDescriptorProto_Type, label *descriptorpb.FieldDescriptorProto_Label) protoreflect.FieldDescriptor { + t.Helper() + fileProto := &descriptorpb.FileDescriptorProto{ + Name: proto.String("test.proto"), + Package: proto.String("test"), + MessageType: []*descriptorpb.DescriptorProto{ + { + Name: proto.String("Msg"), + Field: []*descriptorpb.FieldDescriptorProto{ + { + Name: proto.String("val"), + Number: proto.Int32(1), + Type: fieldType.Enum(), + Label: label, + }, + }, + }, + }, + Syntax: proto.String("proto3"), + } + file, err := protodesc.NewFile(fileProto, nil) + require.NoError(t, err) + return file.Messages().Get(0).Fields().Get(0) +} diff --git a/native_repeated.go b/native_repeated.go new file mode 100644 index 00000000..7a41d8cf --- /dev/null +++ b/native_repeated.go @@ -0,0 +1,183 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "fmt" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "google.golang.org/protobuf/reflect/protoreflect" +) + +//nolint:gochecknoglobals +var ( + repeatedMinItemsDesc = (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("min_items") + repeatedMaxItemsDesc = (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_items") + repeatedUniqueDesc = (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("unique") +) + +// nativeRepeatedEval is a native Go evaluator for repeated list-level rules +// (min_items, max_items, unique). Item-level rules are handled separately +// by the listItems evaluator in repeated.go. +type nativeRepeatedEval struct { + base + minItems *uint64 + maxItems *uint64 + unique bool +} + +func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + list := val.List() + size := uint64(list.Len()) //nolint:gosec + + // min_items + if n.minItems != nil && size < *n.minItems { + return n.newViolation(repeatedFieldRulesDesc, repeatedMinItemsDesc, + "repeated.min_items", + fmt.Sprintf("must contain at least %d item(s)", *n.minItems), + val, protoreflect.ValueOfUint64(*n.minItems)) + } + + // max_items + if n.maxItems != nil && size > *n.maxItems { + return n.newViolation(repeatedFieldRulesDesc, repeatedMaxItemsDesc, + "repeated.max_items", + fmt.Sprintf("must contain no more than %d item(s)", *n.maxItems), + val, protoreflect.ValueOfUint64(*n.maxItems)) + } + + // unique + if n.unique && !isUnique(list) { + return n.newViolation(repeatedFieldRulesDesc, repeatedUniqueDesc, + "repeated.unique", + "repeated value must contain unique items", + val, protoreflect.ValueOfBool(true)) + } + + return nil +} + +// isUnique checks whether all elements in the list are distinct. +func isUnique(list protoreflect.List) bool { + length := list.Len() + if length <= 1 { + return true + } + // type-specific maps avoid any-boxing allocations + switch list.Get(0).Interface().(type) { + case int32: + return isUniqueTyped[int32](list, length) + case int64: + return isUniqueTyped[int64](list, length) + case uint32: + return isUniqueTyped[uint32](list, length) + case uint64: + return isUniqueTyped[uint64](list, length) + case float32: + return isUniqueTyped[float32](list, length) + case float64: + return isUniqueTyped[float64](list, length) + case string: + return isUniqueTyped[string](list, length) + case bool: + return isUniqueTyped[bool](list, length) + case protoreflect.EnumNumber: + return isUniqueTyped[protoreflect.EnumNumber](list, length) + case []byte: + return isUniqueBytes(list, length) + default: + return false // message, list, and map types are not supported, only enum and scalars + } +} + +func isUniqueTyped[T comparable](list protoreflect.List, length int) bool { + seen := make(map[T]struct{}, length) + for i := range length { + key, ok := list.Get(i).Interface().(T) + if !ok { + // should never happen, but just in case + return false + } + if _, exists := seen[key]; exists { + return false + } + seen[key] = struct{}{} + } + return true +} + +func isUniqueBytes(list protoreflect.List, length int) bool { + seen := make(map[string]struct{}, length) + for i := range length { + byteVal := list.Get(i).Bytes() + // []byte is not comparable; convert to string for use as map key. + // this is the same action performed by CEL in library.uniqueBytes + key := string(byteVal) + if _, exists := seen[key]; exists { + return false + } + seen[key] = struct{}{} + } + return true +} + +func (n nativeRepeatedEval) Tautology() bool { + return false +} + +var _ evaluator = nativeRepeatedEval{} + +// tryNativeRepeatedRules attempts to build a native Go evaluator for +// repeated list-level rules (min_items, max_items, unique). +// Returns nil if the rules can't be handled natively. +func tryNativeRepeatedRules(base base, rules *validate.RepeatedRules) evaluator { + if rules == nil { + return nil + } + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + var minItems *uint64 + if rules.HasMinItems() { + minItems = ptr(rules.GetMinItems()) + hasRule = true + } + + var maxItems *uint64 + if rules.HasMaxItems() { + maxItems = ptr(rules.GetMaxItems()) + hasRule = true + } + + unique := false + if rules.GetUnique() { + unique = true + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeRepeatedEval{ + base: base, + minItems: minItems, + maxItems: maxItems, + unique: unique, + } +} diff --git a/native_repeated_test.go b/native_repeated_test.go new file mode 100644 index 00000000..b4b58e85 --- /dev/null +++ b/native_repeated_test.go @@ -0,0 +1,197 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + examplev1 "buf.build/go/protovalidate/internal/gen/tests/example/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +func TestNativeRepeatedMinItems(t *testing.T) { + t.Parallel() + eval := buildNativeRepeated(t, validate.RepeatedRules_builder{MinItems: proto.Uint64(3)}.Build()) + require.NotNil(t, eval) + + // 3 items passes + list := newInt32List(t, 1, 2, 3) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfList(list), &validationConfig{})) + + // 2 items fails + list = newInt32List(t, 1, 2) + err := eval.Evaluate(nil, protoreflect.ValueOfList(list), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "repeated.min_items", valErr.Violations[0].Proto.GetRuleId()) +} + +func TestNativeRepeatedMaxItems(t *testing.T) { + t.Parallel() + eval := buildNativeRepeated(t, validate.RepeatedRules_builder{MaxItems: proto.Uint64(2)}.Build()) + require.NotNil(t, eval) + + // 2 items passes + list := newInt32List(t, 1, 2) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfList(list), &validationConfig{})) + + // 3 items fails + list = newInt32List(t, 1, 2, 3) + err := eval.Evaluate(nil, protoreflect.ValueOfList(list), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "repeated.max_items", valErr.Violations[0].Proto.GetRuleId()) +} + +func TestNativeRepeatedUnique(t *testing.T) { + t.Parallel() + eval := buildNativeRepeated(t, validate.RepeatedRules_builder{Unique: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + + // unique items passes + list := newInt32List(t, 1, 2, 3) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfList(list), &validationConfig{})) + + // duplicate fails + list = newInt32List(t, 1, 2, 1) + err := eval.Evaluate(nil, protoreflect.ValueOfList(list), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "repeated.unique", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "repeated value must contain unique items", valErr.Violations[0].Proto.GetMessage()) +} + +func TestNativeRepeatedUnique_Empty(t *testing.T) { + t.Parallel() + eval := buildNativeRepeated(t, validate.RepeatedRules_builder{Unique: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + + list := newInt32List(t) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfList(list), &validationConfig{})) +} + +func TestNativeRepeatedUnique_Single(t *testing.T) { + t.Parallel() + eval := buildNativeRepeated(t, validate.RepeatedRules_builder{Unique: proto.Bool(true)}.Build()) + require.NotNil(t, eval) + + list := newInt32List(t, 42) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfList(list), &validationConfig{})) +} + +func TestTryNativeRepeatedRules_ReturnsNil(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.RepeatedRules + }{ + {"nil_rules", nil}, + {"empty_rules", validate.RepeatedRules_builder{}.Build()}, + {"items_only", validate.RepeatedRules_builder{ + Items: validate.FieldRules_builder{ + Int32: validate.Int32Rules_builder{Gt: proto.Int32(0)}.Build(), + }.Build(), + }.Build()}, + {"unique_false", validate.RepeatedRules_builder{Unique: proto.Bool(false)}.Build()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Nil(t, tryNativeRepeatedRules(base{}, tt.rules)) + }) + } +} + +func TestNativeUniqueEnums(t *testing.T) { + t.Parallel() + // should fail + { + msg := examplev1.TestUnique_builder{ + Enums: []examplev1.TestEnum{ + examplev1.TestEnum_TEST_ENUM_VAL1, + examplev1.TestEnum_TEST_ENUM_VAL1, + examplev1.TestEnum_TEST_ENUM_VAL2, + examplev1.TestEnum_TEST_ENUM_VAL3, + }, + }.Build() + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())) + require.NoError(t, err) + require.Error(t, validator.Validate(msg)) + } + + // should pass + { + msg := examplev1.TestUnique_builder{ + Enums: []examplev1.TestEnum{ + examplev1.TestEnum_TEST_ENUM_VAL1, + examplev1.TestEnum_TEST_ENUM_VAL2, + examplev1.TestEnum_TEST_ENUM_VAL3, + }, + }.Build() + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())) + require.NoError(t, err) + require.NoError(t, validator.Validate(msg)) + } +} + +func TestNativeRepeatedTautology(t *testing.T) { + t.Parallel() + eval := buildNativeRepeated(t, validate.RepeatedRules_builder{MinItems: proto.Uint64(1)}.Build()) + require.NotNil(t, eval) + assert.False(t, eval.Tautology()) +} + +// --- Helpers --- + +func buildNativeRepeated(t testing.TB, rules *validate.RepeatedRules) evaluator { + t.Helper() + fdesc := newFieldDescriptor(t, descriptorpb.FieldDescriptorProto_TYPE_INT32, + descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum()) + b := base{ + Descriptor: fdesc, + FieldPathElement: fieldPathElement(fdesc), + } + return tryNativeRepeatedRules(b, rules) +} + +// newInt32List creates a protoreflect.List containing the given int32 values. +func newInt32List(t testing.TB, vals ...int32) protoreflect.List { + t.Helper() + + msgType := newDynamicMessageType(t, "test.native", "TestMessage", &descriptorpb.FieldDescriptorProto{ + Name: proto.String("value"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + Label: descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum(), + }) + + template := dynamicpb.NewMessage(msgType.Descriptor()) + list := template.NewField(msgType.Descriptor().Fields().ByName("value")) + for _, v := range vals { + list.List().Append(protoreflect.ValueOfInt32(v)) + } + return list.List() +} diff --git a/native_selection_test.go b/native_selection_test.go new file mode 100644 index 00000000..b5f733c7 --- /dev/null +++ b/native_selection_test.go @@ -0,0 +1,290 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "reflect" + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +// TestNativeEvaluatorSelection verifies that when PV_NATIVE_RULES=true, +// the builder selects native Go evaluators instead of CEL programs. +func TestNativeEvaluatorSelection(t *testing.T) { + tests := []struct { + name string + fieldType descriptorpb.FieldDescriptorProto_Type + label descriptorpb.FieldDescriptorProto_Label + rules *validate.FieldRules + nativeType string // expected evaluator type name + }{ + { + name: "int32_gt", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_INT32, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + Int32: validate.Int32Rules_builder{Gt: proto.Int32(0)}.Build(), + }.Build(), + nativeType: "nativeNumericCompare[int32]", + }, + { + name: "int64_gte", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_INT64, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + Int64: validate.Int64Rules_builder{Gte: proto.Int64(0)}.Build(), + }.Build(), + nativeType: "nativeNumericCompare[int64]", + }, + { + name: "uint32_lt", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_UINT32, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + Uint32: validate.UInt32Rules_builder{Lt: proto.Uint32(100)}.Build(), + }.Build(), + nativeType: "nativeNumericCompare[uint32]", + }, + { + name: "uint64_const", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_UINT64, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + Uint64: validate.UInt64Rules_builder{Const: proto.Uint64(42)}.Build(), + }.Build(), + nativeType: "nativeNumericCompare[uint64]", + }, + { + name: "float_lt", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_FLOAT, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + Float: validate.FloatRules_builder{Lt: proto.Float32(1.0)}.Build(), + }.Build(), + nativeType: "nativeNumericCompare[float32]", + }, + { + name: "double_gte", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_DOUBLE, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + Double: validate.DoubleRules_builder{Gte: proto.Float64(0)}.Build(), + }.Build(), + nativeType: "nativeNumericCompare[float64]", + }, + { + name: "string_min_len", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_STRING, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + String: validate.StringRules_builder{MinLen: proto.Uint64(1)}.Build(), + }.Build(), + nativeType: "nativeStringEval", + }, + { + name: "bool_const", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_BOOL, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + Bool: validate.BoolRules_builder{Const: proto.Bool(true)}.Build(), + }.Build(), + nativeType: "nativeBoolEval", + }, + { + name: "bytes_min_len", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_BYTES, + label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL, + rules: validate.FieldRules_builder{ + Bytes: validate.BytesRules_builder{MinLen: proto.Uint64(1)}.Build(), + }.Build(), + nativeType: "nativeBytesEval", + }, + { + name: "repeated_min_items", + fieldType: descriptorpb.FieldDescriptorProto_TYPE_INT32, + label: descriptorpb.FieldDescriptorProto_LABEL_REPEATED, + rules: validate.FieldRules_builder{ + Repeated: validate.RepeatedRules_builder{MinItems: proto.Uint64(1)}.Build(), + }.Build(), + nativeType: "nativeRepeatedEval", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // With PV_NATIVE_RULES=true, should use native evaluator. + t.Setenv("PV_NATIVE_RULES", "true") + msgType := newDynamicMessageType(t, "test.sel", "Msg", &descriptorpb.FieldDescriptorProto{ + Name: proto.String("value"), + Number: proto.Int32(1), + Type: tt.fieldType.Enum(), + Label: tt.label.Enum(), + Options: fieldOpts(tt.rules), + }) + v, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) + require.NoError(t, err) + + evals := findFieldEvaluators(t, v, msgType.Descriptor(), "value") + require.NotEmpty(t, evals, "expected at least one evaluator for field") + assertHasEvaluatorType(t, evals, tt.nativeType) + assertNoCELPrograms(t, evals) + }) + } +} + +// TestNativeEvaluatorSelection_Enum tests enum separately since it needs +// an enum descriptor in the file. +func TestNativeEvaluatorSelection_Enum(t *testing.T) { + t.Setenv("PV_NATIVE_RULES", "true") + + enumDesc := &descriptorpb.EnumDescriptorProto{ + Name: proto.String("TestEnum"), + Value: []*descriptorpb.EnumValueDescriptorProto{ + {Name: proto.String("UNSPECIFIED"), Number: proto.Int32(0)}, + {Name: proto.String("ONE"), Number: proto.Int32(1)}, + }, + } + msgType := newDynamicMessageTypeWithEnum(t, "test.sel", "EnumMsg", enumDesc, &descriptorpb.FieldDescriptorProto{ + Name: proto.String("value"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_ENUM.Enum(), + TypeName: proto.String(".test.sel.TestEnum"), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Options: fieldOpts(validate.FieldRules_builder{ + Enum: validate.EnumRules_builder{In: []int32{0, 1}}.Build(), + }.Build()), + }) + + v, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) + require.NoError(t, err) + + evals := findFieldEvaluators(t, v, msgType.Descriptor(), "value") + require.NotEmpty(t, evals) + assertHasEvaluatorType(t, evals, "nativeEnumEval") + assertNoCELPrograms(t, evals) +} + +// TestNativeEvaluatorSelection_Map tests map separately since it needs +// a map field descriptor. +func TestNativeEvaluatorSelection_Map(t *testing.T) { + t.Setenv("PV_NATIVE_RULES", "true") + + msgType := newDynamicMapMessageType(t, "test.sel", "MapMsg", + descriptorpb.FieldDescriptorProto_TYPE_STRING, + descriptorpb.FieldDescriptorProto_TYPE_STRING, + validate.FieldRules_builder{ + Map: validate.MapRules_builder{MinPairs: proto.Uint64(1)}.Build(), + }.Build(), + ) + + v, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) + require.NoError(t, err) + + evals := findFieldEvaluators(t, v, msgType.Descriptor(), "entries") + require.NotEmpty(t, evals) + assertHasEvaluatorType(t, evals, "nativeMapEval") + assertNoCELPrograms(t, evals) +} + +// TestCELFallbackWithoutNativeFlag verifies that without PV_NATIVE_RULES, +// the builder uses CEL programs. +func TestCELFallbackWithoutNativeFlag(t *testing.T) { + t.Setenv("PV_NATIVE_RULES", "false") + + msgType := newDynamicMessageType(t, "test.sel", "CELMsg", &descriptorpb.FieldDescriptorProto{ + Name: proto.String("value"), + Number: proto.Int32(1), + Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Options: fieldOpts(validate.FieldRules_builder{ + Int32: validate.Int32Rules_builder{Gt: proto.Int32(0)}.Build(), + }.Build()), + }) + + v, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) + require.NoError(t, err) + + evals := findFieldEvaluators(t, v, msgType.Descriptor(), "value") + require.NotEmpty(t, evals) + assertHasEvaluatorType(t, evals, "celPrograms") +} + +// --- Helpers --- + +// findFieldEvaluators extracts the Rules evaluators for a named field +// from a built validator's evaluator tree. +func findFieldEvaluators( + t testing.TB, + v Validator, + desc protoreflect.MessageDescriptor, + fieldName string, +) evaluators { + t.Helper() + val, ok := v.(*validator) + require.True(t, ok, "expected *validator") + + msgEval := val.builder.Load(desc) + msg, ok := msgEval.(*message) + require.True(t, ok, "expected *message evaluator") + + for _, ne := range msg.nestedEvaluators { + f, ok := ne.(field) + if !ok { + continue + } + if f.Value.Descriptor.Name() == protoreflect.Name(fieldName) { + return f.Value.Rules + } + } + t.Fatalf("field %q not found in evaluator tree", fieldName) + return nil +} + +// assertHasEvaluatorType checks that at least one evaluator in the slice +// has the expected type name. +func assertHasEvaluatorType(t testing.TB, evals evaluators, typeName string) { + t.Helper() + for _, e := range evals { + if evaluatorTypeName(e) == typeName { + return + } + } + var actual []string + for _, e := range evals { + actual = append(actual, evaluatorTypeName(e)) + } + t.Errorf("expected evaluator type %q, got %v", typeName, actual) +} + +// assertNoCELPrograms checks that no evaluator in the slice is a celPrograms. +func assertNoCELPrograms(t testing.TB, evals evaluators) { + t.Helper() + for _, e := range evals { + assert.NotEqual(t, "celPrograms", evaluatorTypeName(e), + "expected no CEL programs when native rules are enabled") + } +} + +// evaluatorTypeName returns a string identifying the evaluator's concrete type +// using reflection, so it automatically handles new evaluator types. +func evaluatorTypeName(e evaluator) string { + return reflect.TypeOf(e).Name() +} diff --git a/native_string.go b/native_string.go new file mode 100644 index 00000000..d4a56513 --- /dev/null +++ b/native_string.go @@ -0,0 +1,703 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "errors" + "fmt" + "regexp" + "slices" + "strings" + "unicode/utf8" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "buf.build/go/protovalidate/cel" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// stringDescriptors bundles the field descriptors for StringRules. +type stringDescriptors struct { + ruleDesc protoreflect.FieldDescriptor // FieldRules.string + constDesc protoreflect.FieldDescriptor + lenDesc protoreflect.FieldDescriptor + minLenDesc protoreflect.FieldDescriptor + maxLenDesc protoreflect.FieldDescriptor + lenBytesDesc protoreflect.FieldDescriptor + minBytesDesc protoreflect.FieldDescriptor + maxBytesDesc protoreflect.FieldDescriptor + patternDesc protoreflect.FieldDescriptor + prefixDesc protoreflect.FieldDescriptor + suffixDesc protoreflect.FieldDescriptor + containsDesc protoreflect.FieldDescriptor + notContainsDesc protoreflect.FieldDescriptor + inDesc protoreflect.FieldDescriptor + notInDesc protoreflect.FieldDescriptor + hostNameDesc protoreflect.FieldDescriptor + hostAndPortDesc protoreflect.FieldDescriptor + emailDesc protoreflect.FieldDescriptor + ipDesc protoreflect.FieldDescriptor + ipv4Desc protoreflect.FieldDescriptor + ipv6Desc protoreflect.FieldDescriptor + uriDesc protoreflect.FieldDescriptor + uriRefDesc protoreflect.FieldDescriptor + addressDesc protoreflect.FieldDescriptor + uuidDesc protoreflect.FieldDescriptor + tuuidDesc protoreflect.FieldDescriptor + ipPrefixLenDesc protoreflect.FieldDescriptor + ipv4PrefixLenDesc protoreflect.FieldDescriptor + ipv6PrefixLenDesc protoreflect.FieldDescriptor + ipPrefixDesc protoreflect.FieldDescriptor + ipv4PrefixDesc protoreflect.FieldDescriptor + ipv6PrefixDesc protoreflect.FieldDescriptor + ulidDesc protoreflect.FieldDescriptor + wellKnownRegexDesc protoreflect.FieldDescriptor + strictDesc protoreflect.FieldDescriptor +} + +func makeStringDescriptors() stringDescriptors { + rulesDesc := (*validate.StringRules)(nil).ProtoReflect().Descriptor() + return stringDescriptors{ + ruleDesc: fieldRulesDesc.Fields().ByName("string"), + constDesc: rulesDesc.Fields().ByName("const"), + lenDesc: rulesDesc.Fields().ByName("len"), + minLenDesc: rulesDesc.Fields().ByName("min_len"), + maxLenDesc: rulesDesc.Fields().ByName("max_len"), + lenBytesDesc: rulesDesc.Fields().ByName("len_bytes"), + minBytesDesc: rulesDesc.Fields().ByName("min_bytes"), + maxBytesDesc: rulesDesc.Fields().ByName("max_bytes"), + patternDesc: rulesDesc.Fields().ByName("pattern"), + prefixDesc: rulesDesc.Fields().ByName("prefix"), + suffixDesc: rulesDesc.Fields().ByName("suffix"), + containsDesc: rulesDesc.Fields().ByName("contains"), + notContainsDesc: rulesDesc.Fields().ByName("not_contains"), + inDesc: rulesDesc.Fields().ByName("in"), + notInDesc: rulesDesc.Fields().ByName("not_in"), + emailDesc: rulesDesc.Fields().ByName("email"), + hostNameDesc: rulesDesc.Fields().ByName("hostname"), + ipDesc: rulesDesc.Fields().ByName("ip"), + ipv4Desc: rulesDesc.Fields().ByName("ipv4"), + ipv6Desc: rulesDesc.Fields().ByName("ipv6"), + uriDesc: rulesDesc.Fields().ByName("uri"), + uriRefDesc: rulesDesc.Fields().ByName("uri_ref"), + addressDesc: rulesDesc.Fields().ByName("address"), + uuidDesc: rulesDesc.Fields().ByName("uuid"), + tuuidDesc: rulesDesc.Fields().ByName("tuuid"), + ipPrefixLenDesc: rulesDesc.Fields().ByName("ip_with_prefixlen"), + ipv4PrefixLenDesc: rulesDesc.Fields().ByName("ipv4_with_prefixlen"), + ipv6PrefixLenDesc: rulesDesc.Fields().ByName("ipv6_with_prefixlen"), + ipPrefixDesc: rulesDesc.Fields().ByName("ip_prefix"), + ipv4PrefixDesc: rulesDesc.Fields().ByName("ipv4_prefix"), + ipv6PrefixDesc: rulesDesc.Fields().ByName("ipv6_prefix"), + hostAndPortDesc: rulesDesc.Fields().ByName("host_and_port"), + ulidDesc: rulesDesc.Fields().ByName("ulid"), + wellKnownRegexDesc: rulesDesc.Fields().ByName("well_known_regex"), + strictDesc: rulesDesc.Fields().ByName("strict"), + } +} + +//nolint:gochecknoglobals +var strDescs = makeStringDescriptors() + +var ( + uuidRegexp = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + tuuidRegexp = regexp.MustCompile(`^[0-9a-fA-F]{32}$`) + ulidRegexp = regexp.MustCompile(`^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$`) + looseRegexp = regexp.MustCompile(`^[^\x00\x0A\x0D]+$`) + headerNameRegexp = regexp.MustCompile(`^:?[0-9a-zA-Z!#$%&\\'*+-.^_|~\x60]+$`) + headerValueRegexp = regexp.MustCompile(`^[^\x00-\x08\x0A-\x1F\x7F]*$`) +) + +// stringWellKnownRule describes a well-known string format constraint. +// It bundles the field descriptor, rule IDs, messages, and validation +// function so that all well-known checks share a single generic method. +type stringWellKnownRule struct { + desc protoreflect.FieldDescriptor + ruleID string // e.g. "string.ip" + emptyRuleID string // e.g. "string.ip_empty"; empty means skip the empty check + mainMsg string // e.g. "must be a valid IP address" + emptyMsg string // e.g. "value is empty, which is not a valid IP address" + validate func(string) bool +} + +//nolint:gochecknoglobals +var ( + stringRuleEmail = stringWellKnownRule{ + desc: strDescs.emailDesc, + ruleID: "string.email", + emptyRuleID: "string.email_empty", + mainMsg: "must be a valid email address", + emptyMsg: "value is empty, which is not a valid email address", + validate: cel.IsEmail, + } + stringRuleHostname = stringWellKnownRule{ + desc: strDescs.hostNameDesc, + ruleID: "string.hostname", + emptyRuleID: "string.hostname_empty", + mainMsg: "must be a valid hostname", + emptyMsg: "value is empty, which is not a valid hostname", + validate: cel.IsHostname, + } + stringRuleIP = stringWellKnownRule{ + desc: strDescs.ipDesc, + ruleID: "string.ip", + emptyRuleID: "string.ip_empty", + mainMsg: "must be a valid IP address", + emptyMsg: "value is empty, which is not a valid IP address", + validate: func(s string) bool { return cel.IsIP(s, 0) }, + } + stringRuleIPv4 = stringWellKnownRule{ + desc: strDescs.ipv4Desc, + ruleID: "string.ipv4", + emptyRuleID: "string.ipv4_empty", + mainMsg: "must be a valid IPv4 address", + emptyMsg: "value is empty, which is not a valid IPv4 address", + validate: func(s string) bool { return cel.IsIP(s, 4) }, + } + stringRuleIPv6 = stringWellKnownRule{ + desc: strDescs.ipv6Desc, + ruleID: "string.ipv6", + emptyRuleID: "string.ipv6_empty", + mainMsg: "must be a valid IPv6 address", + emptyMsg: "value is empty, which is not a valid IPv6 address", + validate: func(s string) bool { return cel.IsIP(s, 6) }, + } + stringRuleURI = stringWellKnownRule{ + desc: strDescs.uriDesc, + ruleID: "string.uri", + emptyRuleID: "string.uri_empty", + mainMsg: "must be a valid URI", + emptyMsg: "value is empty, which is not a valid URI", + validate: cel.IsURI, + } + stringRuleURIRef = stringWellKnownRule{ + desc: strDescs.uriRefDesc, + ruleID: "string.uri_ref", + mainMsg: "must be a valid URI Reference", + validate: cel.IsURIRef, + } + stringRuleAddress = stringWellKnownRule{ + desc: strDescs.addressDesc, + ruleID: "string.address", + emptyRuleID: "string.address_empty", + mainMsg: "must be a valid hostname, or ip address", + emptyMsg: "value is empty, which is not a valid hostname, or ip address", + validate: func(s string) bool { return cel.IsHostname(s) || cel.IsIP(s, 0) }, + } + stringRuleUUID = stringWellKnownRule{ + desc: strDescs.uuidDesc, + ruleID: "string.uuid", + emptyRuleID: "string.uuid_empty", + mainMsg: "must be a valid UUID", + emptyMsg: "value is empty, which is not a valid UUID", + validate: uuidRegexp.MatchString, + } + stringRuleTUUID = stringWellKnownRule{ + desc: strDescs.tuuidDesc, + ruleID: "string.tuuid", + emptyRuleID: "string.tuuid_empty", + mainMsg: "must be a valid trimmed UUID", + emptyMsg: "value is empty, which is not a valid trimmed UUID", + validate: tuuidRegexp.MatchString, + } + stringRuleIPPrefixLen = stringWellKnownRule{ + desc: strDescs.ipPrefixLenDesc, + ruleID: "string.ip_with_prefixlen", + emptyRuleID: "string.ip_with_prefixlen_empty", + mainMsg: "must be a valid IP prefix", + emptyMsg: "value is empty, which is not a valid IP prefix", + validate: func(s string) bool { return cel.IsIPPrefix(s, 0, false) }, + } + stringRuleIPv4PrefixLen = stringWellKnownRule{ + desc: strDescs.ipv4PrefixLenDesc, + ruleID: "string.ipv4_with_prefixlen", + emptyRuleID: "string.ipv4_with_prefixlen_empty", + mainMsg: "must be a valid IPv4 address with prefix length", + emptyMsg: "value is empty, which is not a valid IPv4 address with prefix length", + validate: func(s string) bool { return cel.IsIPPrefix(s, 4, false) }, + } + stringRuleIPv6PrefixLen = stringWellKnownRule{ + desc: strDescs.ipv6PrefixLenDesc, + ruleID: "string.ipv6_with_prefixlen", + emptyRuleID: "string.ipv6_with_prefixlen_empty", + mainMsg: "must be a valid IPv6 address with prefix length", + emptyMsg: "value is empty, which is not a valid IPv6 address with prefix length", + validate: func(s string) bool { return cel.IsIPPrefix(s, 6, false) }, + } + stringRuleIPPrefix = stringWellKnownRule{ + desc: strDescs.ipPrefixDesc, + ruleID: "string.ip_prefix", + emptyRuleID: "string.ip_prefix_empty", + mainMsg: "must be a valid IP prefix", + emptyMsg: "value is empty, which is not a valid IP prefix", + validate: func(s string) bool { return cel.IsIPPrefix(s, 0, true) }, + } + stringRuleIPv4Prefix = stringWellKnownRule{ + desc: strDescs.ipv4PrefixDesc, + ruleID: "string.ipv4_prefix", + emptyRuleID: "string.ipv4_prefix_empty", + mainMsg: "must be a valid IPv4 prefix", + emptyMsg: "value is empty, which is not a valid IPv4 prefix", + validate: func(s string) bool { return cel.IsIPPrefix(s, 4, true) }, + } + stringRuleIPv6Prefix = stringWellKnownRule{ + desc: strDescs.ipv6PrefixDesc, + ruleID: "string.ipv6_prefix", + emptyRuleID: "string.ipv6_prefix_empty", + mainMsg: "must be a valid IPv6 prefix", + emptyMsg: "value is empty, which is not a valid IPv6 prefix", + validate: func(s string) bool { return cel.IsIPPrefix(s, 6, true) }, + } + stringRuleHostAndPort = stringWellKnownRule{ + desc: strDescs.hostAndPortDesc, + ruleID: "string.host_and_port", + emptyRuleID: "string.host_and_port_empty", + mainMsg: "must be a valid host (hostname or IP address) and port pair", + emptyMsg: "value is empty, which is not a valid host and port pair", + validate: func(s string) bool { return cel.IsHostAndPort(s, true) }, + } + stringRuleULID = stringWellKnownRule{ + desc: strDescs.ulidDesc, + ruleID: "string.ulid", + emptyRuleID: "string.ulid_empty", + mainMsg: "must be a valid ULID", + emptyMsg: "value is empty, which is not a valid ULID", + validate: ulidRegexp.MatchString, + } +) + +// nativeStringEval is a native Go evaluator for string rules. +// It replaces CEL evaluation with direct Go operations for +// const, in, not_in, len, min_len, max_len, len_bytes, min_bytes, +// max_bytes, pattern, prefix, suffix, contains, and not_contains. +type nativeStringEval struct { + base + constVal *string + inVals []string + notInVals []string + exactLen *uint64 + minLen *uint64 + maxLen *uint64 + exactBytes *uint64 + minBytes *uint64 + maxBytes *uint64 + pattern *regexp.Regexp + patternStr string + prefix *string + suffix *string + contains *string + notContains *string + wellKnownRule *stringWellKnownRule + knownRegex validate.KnownRegex + strict bool +} + +func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + strVal := val.String() + + var runeCount uint64 + if n.exactLen != nil || n.minLen != nil || n.maxLen != nil { + runeCount = uint64(utf8.RuneCountInString(strVal)) //nolint:gosec + err := n.evaluateLength(runeCount, val) + if err != nil { + return err + } + } + + var byteCount uint64 + if n.exactBytes != nil || n.minBytes != nil || n.maxBytes != nil { + byteCount = uint64(len(strVal)) + err := n.evaluateByteLength(byteCount, val) + if err != nil { + return err + } + } + + // const + if n.constVal != nil && strVal != *n.constVal { + return n.newViolation(strDescs.ruleDesc, strDescs.constDesc, + "string.const", fmt.Sprintf("must equal `%s`", *n.constVal), + val, protoreflect.ValueOfString(*n.constVal)) + } + + // pattern + if n.pattern != nil && !n.pattern.MatchString(strVal) { + return n.newViolation(strDescs.ruleDesc, strDescs.patternDesc, + "string.pattern", fmt.Sprintf("does not match regex pattern `%s`", n.patternStr), + val, protoreflect.ValueOfString(n.patternStr)) + } + + // prefix + if n.prefix != nil && !strings.HasPrefix(strVal, *n.prefix) { + return n.newViolation(strDescs.ruleDesc, strDescs.prefixDesc, + "string.prefix", fmt.Sprintf("does not have prefix `%s`", *n.prefix), + val, protoreflect.ValueOfString(*n.prefix)) + } + + // suffix + if n.suffix != nil && !strings.HasSuffix(strVal, *n.suffix) { + return n.newViolation(strDescs.ruleDesc, strDescs.suffixDesc, + "string.suffix", fmt.Sprintf("does not have suffix `%s`", *n.suffix), + val, protoreflect.ValueOfString(*n.suffix)) + } + + // contains + if n.contains != nil && !strings.Contains(strVal, *n.contains) { + return n.newViolation(strDescs.ruleDesc, strDescs.containsDesc, + "string.contains", fmt.Sprintf("does not contain substring `%s`", *n.contains), + val, protoreflect.ValueOfString(*n.contains)) + } + + // not_contains + if n.notContains != nil && strings.Contains(strVal, *n.notContains) { + return n.newViolation(strDescs.ruleDesc, strDescs.notContainsDesc, + "string.not_contains", fmt.Sprintf("value contains substring `%s`", *n.notContains), + val, protoreflect.ValueOfString(*n.notContains)) + } + + // in + if len(n.inVals) > 0 && !slices.Contains(n.inVals, strVal) { + return n.newViolation(strDescs.ruleDesc, strDescs.inDesc, + "string.in", "must be in list "+formatStringList(n.inVals), + val, protoreflect.ValueOfString(strVal)) + } + + // not_in + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, strVal) { + return n.newViolation(strDescs.ruleDesc, strDescs.notInDesc, + "string.not_in", "must not be in list "+formatStringList(n.notInVals), + val, protoreflect.ValueOfString(strVal)) + } + + if n.wellKnownRule != nil { + if err := n.checkWellKnown(strVal, val); err != nil { + return err + } + } else if n.knownRegex != 0 { + if err := n.checkKnownRegex(strVal, val); err != nil { + return err + } + } + + return nil +} + +func (n nativeStringEval) checkWellKnown(strVal string, val protoreflect.Value) error { + rule := n.wellKnownRule + if rule.emptyRuleID != "" && strVal == "" { + return n.newViolation(strDescs.ruleDesc, rule.desc, + rule.emptyRuleID, rule.emptyMsg, + val, protoreflect.ValueOfString(strVal)) + } + if !rule.validate(strVal) { + return n.newViolation(strDescs.ruleDesc, rule.desc, + rule.ruleID, rule.mainMsg, + val, protoreflect.ValueOfString(strVal)) + } + return nil +} + +func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) error { + // check if strict is set (it is on by default) + // if not, just validate against the loose rule (^[^\u0000\u000A\u000D]+$) + // if yes, check whether this is a name or value and use the correct strict rule + // ^:?[0-9a-zA-Z!#$%&\\'*+-.^_|~\\x60]+$ for name + // ^[^\u0000-\u0008\u000A-\u001F\u007F]*$ for value + var matcher *regexp.Regexp + var rule string + var msg string + switch n.knownRegex { + case validate.KnownRegex_KNOWN_REGEX_HTTP_HEADER_NAME: + if strVal == "" { + return n.newViolation(strDescs.ruleDesc, strDescs.wellKnownRegexDesc, + "string.well_known_regex.header_name_empty", "value is empty, which is not a valid HTTP header name", + val, protoreflect.ValueOfString(strVal)) + } + matcher = headerNameRegexp + rule = "string.well_known_regex.header_name" + msg = "must be a valid HTTP header name" + case validate.KnownRegex_KNOWN_REGEX_HTTP_HEADER_VALUE: + matcher = headerValueRegexp + rule = "string.well_known_regex.header_value" + msg = "must be a valid HTTP header value" + default: + return nil // should never happen, but just in case + } + if !n.strict { + matcher = looseRegexp + } + if !matcher.MatchString(strVal) { + return n.newViolation(strDescs.ruleDesc, strDescs.wellKnownRegexDesc, + rule, msg, + val, protoreflect.ValueOfString(strVal)) + } + return nil +} + +// it would be worse to unify this and evaluateLength than it is to leave them as +// very similar bits of code +// +//nolint:dupl +func (n nativeStringEval) evaluateByteLength(byteCount uint64, val protoreflect.Value) error { + if n.exactBytes != nil && byteCount != *n.exactBytes { + return n.newViolation(strDescs.ruleDesc, strDescs.lenBytesDesc, + "string.len_bytes", fmt.Sprintf("must be %d bytes", *n.exactBytes), + val, protoreflect.ValueOfUint64(*n.exactBytes)) + } + if n.minBytes != nil && byteCount < *n.minBytes { + return n.newViolation(strDescs.ruleDesc, strDescs.minBytesDesc, + "string.min_bytes", fmt.Sprintf("must be at least %d bytes", *n.minBytes), + val, protoreflect.ValueOfUint64(*n.minBytes)) + } + if n.maxBytes != nil && byteCount > *n.maxBytes { + return n.newViolation(strDescs.ruleDesc, strDescs.maxBytesDesc, + "string.max_bytes", fmt.Sprintf("must be at most %d bytes", *n.maxBytes), + val, protoreflect.ValueOfUint64(*n.maxBytes)) + } + return nil +} + +// it would be worse to unify this and evaluateByteLength than it is to leave them as +// very similar bits of code +// +//nolint:dupl +func (n nativeStringEval) evaluateLength(runeCount uint64, val protoreflect.Value) error { + if n.exactLen != nil && runeCount != *n.exactLen { + return n.newViolation(strDescs.ruleDesc, strDescs.lenDesc, + "string.len", fmt.Sprintf("must be %d characters", *n.exactLen), + val, protoreflect.ValueOfUint64(*n.exactLen)) + } + if n.minLen != nil && runeCount < *n.minLen { + return n.newViolation(strDescs.ruleDesc, strDescs.minLenDesc, + "string.min_len", fmt.Sprintf("must be at least %d characters", *n.minLen), + val, protoreflect.ValueOfUint64(*n.minLen)) + } + if n.maxLen != nil && runeCount > *n.maxLen { + return n.newViolation(strDescs.ruleDesc, strDescs.maxLenDesc, + "string.max_len", fmt.Sprintf("must be at most %d characters", *n.maxLen), + val, protoreflect.ValueOfUint64(*n.maxLen)) + } + return nil +} + +func (n nativeStringEval) Tautology() bool { + return false +} + +var _ evaluator = nativeStringEval{} + +var errUnsupportedWellKnown = errors.New("unsupported well-known string constraint") + +// parseStringWellKnown maps a StringRules well-known oneof to a +// *stringWellKnownRule (for format constraints) or a KnownRegex + +// strict flag (for well-known regex constraints). +func parseStringWellKnown(rules *validate.StringRules) ( + *stringWellKnownRule, validate.KnownRegex, bool, error, +) { + switch { + case rules.GetEmail(): + return &stringRuleEmail, 0, false, nil + case rules.GetHostname(): + return &stringRuleHostname, 0, false, nil + case rules.GetIp(): + return &stringRuleIP, 0, false, nil + case rules.GetIpv4(): + return &stringRuleIPv4, 0, false, nil + case rules.GetIpv6(): + return &stringRuleIPv6, 0, false, nil + case rules.GetUri(): + return &stringRuleURI, 0, false, nil + case rules.GetUriRef(): + return &stringRuleURIRef, 0, false, nil + case rules.GetAddress(): + return &stringRuleAddress, 0, false, nil + case rules.GetUuid(): + return &stringRuleUUID, 0, false, nil + case rules.GetTuuid(): + return &stringRuleTUUID, 0, false, nil + case rules.GetIpWithPrefixlen(): + return &stringRuleIPPrefixLen, 0, false, nil + case rules.GetIpv4WithPrefixlen(): + return &stringRuleIPv4PrefixLen, 0, false, nil + case rules.GetIpv6WithPrefixlen(): + return &stringRuleIPv6PrefixLen, 0, false, nil + case rules.GetIpPrefix(): + return &stringRuleIPPrefix, 0, false, nil + case rules.GetIpv4Prefix(): + return &stringRuleIPv4Prefix, 0, false, nil + case rules.GetIpv6Prefix(): + return &stringRuleIPv6Prefix, 0, false, nil + case rules.GetUlid(): + return &stringRuleULID, 0, false, nil + case rules.GetHostAndPort(): + return &stringRuleHostAndPort, 0, false, nil + case rules.GetWellKnownRegex() != validate.KnownRegex_KNOWN_REGEX_UNSPECIFIED: + knownRegex := rules.GetWellKnownRegex() + // strict is on by default or if it is explicitly set to true + strict := !rules.HasStrict() || rules.GetStrict() + // intentionally doesn't return a *stringWellKnownRule, because well known regex is a special case + return nil, knownRegex, strict, nil + default: + return nil, 0, false, errUnsupportedWellKnown + } +} + +// tryBuildNativeStringRules attempts to build a native Go evaluator for +// string rules. Returns nil if the rules can't be handled natively. +func tryBuildNativeStringRules(base base, rules *validate.StringRules) evaluator { + if rules == nil { + return nil + } + + // Bail out for custom predefined extensions. + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + var wellKnownRule *stringWellKnownRule + var knownRegex validate.KnownRegex + var strict bool + + if rules.HasWellKnown() { + var err error + wellKnownRule, knownRegex, strict, err = parseStringWellKnown(rules) + if err != nil { + return nil + } + hasRule = true + } + + var constVal *string + if rules.HasConst() { + constVal = ptr(rules.GetConst()) + hasRule = true + } + + var exactLen *uint64 + if rules.HasLen() { + exactLen = ptr(rules.GetLen()) + hasRule = true + } + + var minLen *uint64 + if rules.HasMinLen() { + minLen = ptr(rules.GetMinLen()) + hasRule = true + } + + var maxLen *uint64 + if rules.HasMaxLen() { + maxLen = ptr(rules.GetMaxLen()) + hasRule = true + } + + var exactBytes *uint64 + if rules.HasLenBytes() { + exactBytes = ptr(rules.GetLenBytes()) + hasRule = true + } + + var minBytes *uint64 + if rules.HasMinBytes() { + minBytes = ptr(rules.GetMinBytes()) + hasRule = true + } + + var maxBytes *uint64 + if rules.HasMaxBytes() { + maxBytes = ptr(rules.GetMaxBytes()) + hasRule = true + } + + var compiledPattern *regexp.Regexp + var patternStr string + if rules.HasPattern() { + patternStr = rules.GetPattern() + var err error + compiledPattern, err = regexp.Compile(patternStr) + if err != nil { + // Invalid regex — bail to CEL which will also report a CompilationError. + return nil + } + hasRule = true + } + + var prefix *string + if rules.HasPrefix() { + prefix = ptr(rules.GetPrefix()) + hasRule = true + } + + var suffix *string + if rules.HasSuffix() { + suffix = ptr(rules.GetSuffix()) + hasRule = true + } + + var containsVal *string + if rules.HasContains() { + containsVal = ptr(rules.GetContains()) + hasRule = true + } + + var notContains *string + if rules.HasNotContains() { + notContains = ptr(rules.GetNotContains()) + hasRule = true + } + + var inVals []string + if inVals = rules.GetIn(); len(inVals) > 0 { + hasRule = true + } + + var notInVals []string + if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeStringEval{ + base: base, + constVal: constVal, + inVals: inVals, + notInVals: notInVals, + exactLen: exactLen, + minLen: minLen, + maxLen: maxLen, + exactBytes: exactBytes, + minBytes: minBytes, + maxBytes: maxBytes, + pattern: compiledPattern, + patternStr: patternStr, + prefix: prefix, + suffix: suffix, + contains: containsVal, + notContains: notContains, + wellKnownRule: wellKnownRule, + knownRegex: knownRegex, + strict: strict, + } +} + +// formatStringList formats a []string as [a, b] to match CEL's +// string list formatting (no quoting around elements). +func formatStringList(vals []string) string { + return "[" + strings.Join(vals, ", ") + "]" +} diff --git a/native_string_test.go b/native_string_test.go new file mode 100644 index 00000000..7641ed29 --- /dev/null +++ b/native_string_test.go @@ -0,0 +1,487 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import ( + "testing" + + "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" +) + +func buildNativeString(t testing.TB, rules *validate.StringRules) evaluator { + t.Helper() + fdesc := newFieldDescriptor(t, descriptorpb.FieldDescriptorProto_TYPE_STRING, + descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum()) + b := base{ + Descriptor: fdesc, + FieldPathElement: fieldPathElement(fdesc), + } + return tryBuildNativeStringRules(b, rules) +} + +func TestNativeStringConst(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{Const: proto.String("hello")}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("hello"), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfString("world"), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, "string.const", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "must equal `hello`", valErr.Violations[0].Proto.GetMessage()) +} + +func TestNativeStringLen(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{Len: proto.Uint64(3)}.Build()) + require.NotNil(t, eval) + + // ASCII + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("abc"), &validationConfig{})) + // Unicode: 3 code points + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("αβγ"), &validationConfig{})) + + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("ab"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("abcd"), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfString("ab"), &validationConfig{}) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "string.len", valErr.Violations[0].Proto.GetRuleId()) +} + +func TestNativeStringMinLen(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{MinLen: proto.Uint64(2)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("ab"), &validationConfig{})) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("abc"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("a"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString(""), &validationConfig{})) +} + +func TestNativeStringMaxLen(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{MaxLen: proto.Uint64(3)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString(""), &validationConfig{})) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("abc"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("abcd"), &validationConfig{})) +} + +func TestNativeStringLenBytes(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{LenBytes: proto.Uint64(4)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("abcd"), &validationConfig{})) + // "αβ" is 4 bytes (2 bytes per Greek letter) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("αβ"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("abc"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("abcde"), &validationConfig{})) +} + +func TestNativeStringMinBytes(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{MinBytes: proto.Uint64(2)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("ab"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("a"), &validationConfig{})) +} + +func TestNativeStringMaxBytes(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{MaxBytes: proto.Uint64(3)}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("abc"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("abcd"), &validationConfig{})) +} + +func TestNativeStringPattern(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{Pattern: proto.String("^[a-z]+$")}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("abc"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("ABC"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("abc1"), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfString("ABC"), &validationConfig{}) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "string.pattern", valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, "does not match regex pattern `^[a-z]+$`", valErr.Violations[0].Proto.GetMessage()) +} + +func TestNativeStringPattern_InvalidRegex(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{Pattern: proto.String("[invalid")}.Build()) + assert.Nil(t, eval, "invalid regex should bail to CEL") +} + +func TestNativeStringPrefix(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{Prefix: proto.String("foo")}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("foobar"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("barfoo"), &validationConfig{})) +} + +func TestNativeStringSuffix(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{Suffix: proto.String("bar")}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("foobar"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("barbaz"), &validationConfig{})) +} + +func TestNativeStringContains(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{Contains: proto.String("mid")}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("amidst"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("absent"), &validationConfig{})) +} + +func TestNativeStringNotContains(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{NotContains: proto.String("bad")}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("good"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("badger"), &validationConfig{})) +} + +func TestNativeStringIn(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{In: []string{"a", "b", "c"}}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("a"), &validationConfig{})) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("b"), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfString("d"), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + assert.Equal(t, "string.in", valErr.Violations[0].Proto.GetRuleId()) +} + +func TestNativeStringNotIn(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{NotIn: []string{"x", "y"}}.Build()) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("a"), &validationConfig{})) + require.Error(t, eval.Evaluate(nil, protoreflect.ValueOfString("x"), &validationConfig{})) +} + +func TestNativeStringUnicode(t *testing.T) { + t.Parallel() + + // min_len counts runes, not bytes + eval := buildNativeString(t, validate.StringRules_builder{MinLen: proto.Uint64(1)}.Build()) + require.NotNil(t, eval) + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString("α"), &validationConfig{})) // 1 rune, 2 bytes + + // max_bytes counts bytes + eval2 := buildNativeString(t, validate.StringRules_builder{MaxBytes: proto.Uint64(1)}.Build()) + require.NotNil(t, eval2) + require.Error(t, eval2.Evaluate(nil, protoreflect.ValueOfString("α"), &validationConfig{})) // 2 bytes > 1 +} + +// --- Bail-out tests --- + +func TestTryBuildNativeStringRules_ReturnsNil(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.StringRules + }{ + {"nil_rules", nil}, + {"empty_rules", validate.StringRules_builder{}.Build()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Nil(t, tryBuildNativeStringRules(base{}, tt.rules)) + }) + } +} + +func TestNativeStringWellKnowns(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules *validate.StringRules + valid string + invalid string + ruleID string + message string + emptyRuleID string + emptyMsg string + }{ + { + name: "email", + rules: validate.StringRules_builder{Email: proto.Bool(true)}.Build(), + valid: "user@example.com", + invalid: "not-an-email", + ruleID: "string.email", + message: "must be a valid email address", + emptyRuleID: "string.email_empty", + emptyMsg: "value is empty, which is not a valid email address", + }, + { + name: "hostname", + rules: validate.StringRules_builder{Hostname: proto.Bool(true)}.Build(), + valid: "example.com", + invalid: "-invalid", + ruleID: "string.hostname", + message: "must be a valid hostname", + emptyRuleID: "string.hostname_empty", + emptyMsg: "value is empty, which is not a valid hostname", + }, + { + name: "ip", + rules: validate.StringRules_builder{Ip: proto.Bool(true)}.Build(), + valid: "192.168.1.1", + invalid: "not-valid", + ruleID: "string.ip", + message: "must be a valid IP address", + emptyRuleID: "string.ip_empty", + emptyMsg: "value is empty, which is not a valid IP address", + }, + { + name: "ipv4", + rules: validate.StringRules_builder{Ipv4: proto.Bool(true)}.Build(), + valid: "192.168.1.1", + invalid: "::1", + ruleID: "string.ipv4", + message: "must be a valid IPv4 address", + emptyRuleID: "string.ipv4_empty", + emptyMsg: "value is empty, which is not a valid IPv4 address", + }, + { + name: "ipv6", + rules: validate.StringRules_builder{Ipv6: proto.Bool(true)}.Build(), + valid: "::1", + invalid: "192.168.1.1", + ruleID: "string.ipv6", + message: "must be a valid IPv6 address", + emptyRuleID: "string.ipv6_empty", + emptyMsg: "value is empty, which is not a valid IPv6 address", + }, + { + name: "uri", + rules: validate.StringRules_builder{Uri: proto.Bool(true)}.Build(), + valid: "https://example.com", + invalid: "not a uri", + ruleID: "string.uri", + message: "must be a valid URI", + emptyRuleID: "string.uri_empty", + emptyMsg: "value is empty, which is not a valid URI", + }, + { + name: "uri_ref", + rules: validate.StringRules_builder{UriRef: proto.Bool(true)}.Build(), + valid: "/path/to/resource", + invalid: "not valid ref", + ruleID: "string.uri_ref", + message: "must be a valid URI Reference", + }, + { + name: "address", + rules: validate.StringRules_builder{Address: proto.Bool(true)}.Build(), + valid: "example.com", + invalid: "!@#$%", + ruleID: "string.address", + message: "must be a valid hostname, or ip address", + emptyRuleID: "string.address_empty", + emptyMsg: "value is empty, which is not a valid hostname, or ip address", + }, + { + name: "uuid", + rules: validate.StringRules_builder{Uuid: proto.Bool(true)}.Build(), + valid: "550e8400-e29b-41d4-a716-446655440000", + invalid: "not-a-uuid", + ruleID: "string.uuid", + message: "must be a valid UUID", + emptyRuleID: "string.uuid_empty", + emptyMsg: "value is empty, which is not a valid UUID", + }, + { + name: "tuuid", + rules: validate.StringRules_builder{Tuuid: proto.Bool(true)}.Build(), + valid: "550e8400e29b41d4a716446655440000", + invalid: "not-a-tuuid", + ruleID: "string.tuuid", + message: "must be a valid trimmed UUID", + emptyRuleID: "string.tuuid_empty", + emptyMsg: "value is empty, which is not a valid trimmed UUID", + }, + { + name: "ip_with_prefixlen", + rules: validate.StringRules_builder{IpWithPrefixlen: proto.Bool(true)}.Build(), + valid: "192.168.0.1/24", + invalid: "not-valid", + ruleID: "string.ip_with_prefixlen", + message: "must be a valid IP prefix", + emptyRuleID: "string.ip_with_prefixlen_empty", + emptyMsg: "value is empty, which is not a valid IP prefix", + }, + { + name: "ipv4_with_prefixlen", + rules: validate.StringRules_builder{Ipv4WithPrefixlen: proto.Bool(true)}.Build(), + valid: "192.168.0.1/24", + invalid: "not-valid", + ruleID: "string.ipv4_with_prefixlen", + message: "must be a valid IPv4 address with prefix length", + emptyRuleID: "string.ipv4_with_prefixlen_empty", + emptyMsg: "value is empty, which is not a valid IPv4 address with prefix length", + }, + { + name: "ipv6_with_prefixlen", + rules: validate.StringRules_builder{Ipv6WithPrefixlen: proto.Bool(true)}.Build(), + valid: "::1/128", + invalid: "not-valid", + ruleID: "string.ipv6_with_prefixlen", + message: "must be a valid IPv6 address with prefix length", + emptyRuleID: "string.ipv6_with_prefixlen_empty", + emptyMsg: "value is empty, which is not a valid IPv6 address with prefix length", + }, + { + name: "ip_prefix", + rules: validate.StringRules_builder{IpPrefix: proto.Bool(true)}.Build(), + valid: "192.168.0.0/24", + invalid: "not-valid", + ruleID: "string.ip_prefix", + message: "must be a valid IP prefix", + emptyRuleID: "string.ip_prefix_empty", + emptyMsg: "value is empty, which is not a valid IP prefix", + }, + { + name: "ipv4_prefix", + rules: validate.StringRules_builder{Ipv4Prefix: proto.Bool(true)}.Build(), + valid: "192.168.0.0/24", + invalid: "not-valid", + ruleID: "string.ipv4_prefix", + message: "must be a valid IPv4 prefix", + emptyRuleID: "string.ipv4_prefix_empty", + emptyMsg: "value is empty, which is not a valid IPv4 prefix", + }, + { + name: "ipv6_prefix", + rules: validate.StringRules_builder{Ipv6Prefix: proto.Bool(true)}.Build(), + valid: "2001:db8::/32", + invalid: "not-valid", + ruleID: "string.ipv6_prefix", + message: "must be a valid IPv6 prefix", + emptyRuleID: "string.ipv6_prefix_empty", + emptyMsg: "value is empty, which is not a valid IPv6 prefix", + }, + { + name: "host_and_port", + rules: validate.StringRules_builder{HostAndPort: proto.Bool(true)}.Build(), + valid: "example.com:80", + invalid: "example.com", + ruleID: "string.host_and_port", + message: "must be a valid host (hostname or IP address) and port pair", + emptyRuleID: "string.host_and_port_empty", + emptyMsg: "value is empty, which is not a valid host and port pair", + }, + { + name: "ulid", + rules: validate.StringRules_builder{Ulid: proto.Bool(true)}.Build(), + valid: "01ARZ3NDEKTSV4RRFFQ69G5FAV", + invalid: "not-a-ulid", + ruleID: "string.ulid", + message: "must be a valid ULID", + emptyRuleID: "string.ulid_empty", + emptyMsg: "value is empty, which is not a valid ULID", + }, + { + name: "well_known_regex_header_name", + rules: validate.StringRules_builder{WellKnownRegex: validate.KnownRegex_KNOWN_REGEX_HTTP_HEADER_NAME.Enum()}.Build(), + valid: "Content-Type", + invalid: "invalid header", + ruleID: "string.well_known_regex.header_name", + message: "must be a valid HTTP header name", + emptyRuleID: "string.well_known_regex.header_name_empty", + emptyMsg: "value is empty, which is not a valid HTTP header name", + }, + { + name: "well_known_regex_header_value", + rules: validate.StringRules_builder{WellKnownRegex: validate.KnownRegex_KNOWN_REGEX_HTTP_HEADER_VALUE.Enum()}.Build(), + valid: "application/json", + invalid: "\x00", + ruleID: "string.well_known_regex.header_value", + message: "must be a valid HTTP header value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, tt.rules) + require.NotNil(t, eval) + + require.NoError(t, eval.Evaluate(nil, protoreflect.ValueOfString(tt.valid), &validationConfig{})) + + err := eval.Evaluate(nil, protoreflect.ValueOfString(tt.invalid), &validationConfig{}) + require.Error(t, err) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 1) + assert.Equal(t, tt.ruleID, valErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, tt.message, valErr.Violations[0].Proto.GetMessage()) + + if tt.emptyRuleID != "" { + err = eval.Evaluate(nil, protoreflect.ValueOfString(""), &validationConfig{}) + require.Error(t, err) + var emptyValErr *ValidationError + require.ErrorAs(t, err, &emptyValErr) + require.Len(t, emptyValErr.Violations, 1) + assert.Equal(t, tt.emptyRuleID, emptyValErr.Violations[0].Proto.GetRuleId()) + assert.Equal(t, tt.emptyMsg, emptyValErr.Violations[0].Proto.GetMessage()) + } + }) + } +} + +func TestNativeStringTautology(t *testing.T) { + t.Parallel() + eval := buildNativeString(t, validate.StringRules_builder{MinLen: proto.Uint64(1)}.Build()) + require.NotNil(t, eval) + assert.False(t, eval.Tautology()) +} diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto new file mode 100644 index 00000000..996c44ab --- /dev/null +++ b/proto/tests/example/v1/native_test.proto @@ -0,0 +1,170 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package tests.example.v1; + +import "buf/validate/validate.proto"; +import "gofakeit/gofakeit.proto"; + +message BenchGT { + int32 gt = 1 [ + (buf.validate.field).int32.gt = 0, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gte = 2 [ + (buf.validate.field).int32.gte = 0, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 lt = 3 [ + (buf.validate.field).int32.lt = 101, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 lte = 4 [ + (buf.validate.field).int32.lte = 101, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gtltin = 5 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = 101, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gtltein = 6 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = 101, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gtltex = 7 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lt = -20, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gtlteex = 8 [ + (buf.validate.field).int32.gt = 0, + (buf.validate.field).int32.lte = -20, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gteltin = 9 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = 101, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gteltein = 10 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = 101, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gteltex = 11 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lt = -20, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 gtelteex = 12 [ + (buf.validate.field).int32.gte = 0, + (buf.validate.field).int32.lte = -20, + (gofakeit.generate).tag = "{intrange:1,100}" + ]; + int32 const = 13 [ + (buf.validate.field).int32.const = 10, + (gofakeit.generate).tag = "{intrange:10,10}" + ]; + int32 constgt = 14 [ + (buf.validate.field).int32.const = 10, + (buf.validate.field).int32.gte = 0, + (gofakeit.generate).tag = "{intrange:10,10}" + ]; + int32 in_test = 15 [ + (buf.validate.field).int32 = { + in: [ + 1, + 3, + 5 + ] + }, + (gofakeit.generate).tag = "{intrange:3,3}" + ]; + int32 not_in_test = 16 [ + (buf.validate.field).int32 = { + not_in: [ + 1, + 3, + 5 + ] + }, + (gofakeit.generate).tag = "{intrange:4,4}" + ]; +} + +message BenchTestBytes { + bytes b1 = 1 [(buf.validate.field).bytes = { + not_in: [ + "\x32\x33", + "\x34\x35", + "\x36\x37" + ] + min_len: 1 + max_len: 4 + }]; + bytes b = 4 [(buf.validate.field).bytes = { + in: [ + "\x32\x33", + "\x34\x35", + "\x36\x37" + ] + min_len: 2 + max_len: 2 + }]; +} + +enum TestEnum { + TEST_ENUM_UNSPECIFIED = 0; + TEST_ENUM_VAL1 = 1; + TEST_ENUM_VAL2 = 2; + TEST_ENUM_VAL3 = 3; +} + +message TestUnique { + repeated TestEnum enums = 1 [(buf.validate.field).repeated.unique = true]; +} + +message TestByteMatching { + bytes ip_addr = 1 [ + (buf.validate.field).bytes.ip = true, + (gofakeit.generate).tag = "1234567890123456" + ]; + bytes ipv4_addr = 2 [ + (buf.validate.field).bytes.ipv4 = true, + (gofakeit.generate).tag = "1234" + ]; + bytes ipv6_addr = 3 [ + (buf.validate.field).bytes.ipv6 = true, + (gofakeit.generate).tag = "1234567890123456" + ]; + bytes uuid = 4 [ + (buf.validate.field).bytes.uuid = true, + (gofakeit.generate).tag = "1234567890123456" + ]; +} + +message StringMatching { + string hostname = 1 [ + (buf.validate.field).string.hostname = true, + (gofakeit.generate).tag = "{domainname}" + ]; + string host_and_port = 2 [ + (buf.validate.field).string.host_and_port = true, + (gofakeit.generate).tag = "{domainname}:8080" + ]; +} \ No newline at end of file diff --git a/validator_bench_test.go b/validator_bench_test.go index 4073a15e..3013c3fc 100644 --- a/validator_bench_test.go +++ b/validator_bench_test.go @@ -53,6 +53,67 @@ func BenchmarkComplexSchema(b *testing.B) { benchSuccess(b, &pb.BenchComplexSchema{}) } +func BenchmarkInt32GT(b *testing.B) { + benchSuccess(b, &pb.BenchGT{}) +} + +func BenchmarkTestByteMatching(b *testing.B) { + benchSuccess(b, &pb.TestByteMatching{}) +} + +func TestInt32GT(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.BenchGT{}) +} + +func TestComplexSchema(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.BenchComplexSchema{}) +} + +func TestMap(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.BenchMap{}) +} + +func TestScalar(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.BenchScalar{}) +} + +func TestByteMatching(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.TestByteMatching{}) +} + +func TestStringMatching(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.StringMatching{}) +} + +func TestRepeated(t *testing.T) { + t.Parallel() + t.Run("Scalar", func(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.BenchRepeatedScalar{}) + }) + t.Run("Message", func(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.BenchRepeatedMessage{}) + }) + t.Run("Unique", func(t *testing.T) { + t.Parallel() + t.Run("Scalar", func(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.BenchRepeatedScalarUnique{}) + }) + t.Run("Bytes", func(t *testing.T) { + t.Parallel() + testSuccess(t, &pb.BenchRepeatedBytesUnique{}) + }) + }) +} + func BenchmarkCompile(b *testing.B) { // Measures compile-time allocations for complex schemas msg := &pb.BenchComplexSchema{} @@ -62,9 +123,25 @@ func BenchmarkCompile(b *testing.B) { } } -func benchSuccess(b *testing.B, msg proto.Message) { - b.Helper() +func BenchmarkCompileInt32GT(b *testing.B) { + // Measures compile-time allocations for complex schemas + msg := &pb.BenchGT{} + b.ReportAllocs() + for b.Loop() { + _, _ = New(WithMessages(msg), WithDisableLazy()) + } +} +func testSuccess(t *testing.T, msg proto.Message) { + faker := protogofakeit.New(gofakeit.New(1)) + require.NoError(t, faker.FakeProto(msg)) + val, err := New(WithMessages(msg), WithDisableLazy()) + require.NoError(t, err) + err = val.Validate(msg) + require.NoError(t, err) +} + +func benchSuccess(b *testing.B, msg proto.Message) { faker := protogofakeit.New(gofakeit.New(1)) require.NoError(b, faker.FakeProto(msg)) val, err := New(WithMessages(msg), WithDisableLazy()) @@ -72,10 +149,7 @@ func benchSuccess(b *testing.B, msg proto.Message) { b.ReportAllocs() b.ResetTimer() - b.RunParallel(func(pb *testing.PB) { - for pb.Next() { - err := val.Validate(msg) - require.NoError(b, err) - } - }) + for b.Loop() { + _ = val.Validate(msg) + } } From 1b8bdabe9fa108169e9fd65db9280d27f7165460 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 21 Apr 2026 18:38:36 -0400 Subject: [PATCH 02/14] add missing newline at end of native_test.proto --- proto/tests/example/v1/native_test.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto index 996c44ab..6836943a 100644 --- a/proto/tests/example/v1/native_test.proto +++ b/proto/tests/example/v1/native_test.proto @@ -167,4 +167,4 @@ message StringMatching { (buf.validate.field).string.host_and_port = true, (gofakeit.generate).tag = "{domainname}:8080" ]; -} \ No newline at end of file +} From ef8c35516704882ba120a527f49c1fa8b5a801e8 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 22 Apr 2026 14:42:45 -0400 Subject: [PATCH 03/14] move rules out of cel package and into a package under internal. use a build tag instead of an environment variable to control native vs CEL rules. Make native rules the default. --- Makefile | 19 +- README.md | 16 +- builder.go | 10 +- cel/library.go | 1316 +--------------------------------- cel/library_test.go | 18 - disable_native_rules.go | 21 + dynamic_end_to_end_test.go | 32 +- internal/rules/ipv4.go | 171 +++++ internal/rules/ipv6.go | 301 ++++++++ internal/rules/rules.go | 225 ++++++ internal/rules/rules_test.go | 39 + internal/rules/uri.go | 676 +++++++++++++++++ native_bytes_test.go | 12 +- native_selection_test.go | 23 +- native_string.go | 32 +- 15 files changed, 1542 insertions(+), 1369 deletions(-) create mode 100644 disable_native_rules.go create mode 100644 internal/rules/ipv4.go create mode 100644 internal/rules/ipv6.go create mode 100644 internal/rules/rules.go create mode 100644 internal/rules/rules_test.go create mode 100644 internal/rules/uri.go diff --git a/Makefile b/Makefile index 39caf13d..e9cfd9c3 100644 --- a/Makefile +++ b/Makefile @@ -32,12 +32,14 @@ clean: ## Delete intermediate build artifacts git clean -Xdf .PHONY: test -test: ## Run all unit tests +test: ## Run all unit tests with and without native rules $(GO) test -race -cover ./... + $(GO) test -race -cover -tags="cel_rules" ./... .PHONY: test-opaque test-opaque: ## Test proto opaque API support $(GO) test --tags=protoopaque ./... + $(GO) test -tags="protoopaque cel_rules" ./... .PHONY: lint lint: lint-proto lint-go ## Lint code and protos @@ -57,8 +59,9 @@ lint-fix: $(BIN)/golangci-lint fmt .PHONY: conformance -conformance: $(BIN)/protovalidate-conformance protovalidate-conformance-go ## Run conformance tests +conformance: $(BIN)/protovalidate-conformance protovalidate-conformance-go protovalidate-conformance-go-cel ## Run conformance tests $(BIN)/protovalidate-conformance $(ARGS) $(BIN)/protovalidate-conformance-go --expected_failures=conformance/expected_failures.yaml + $(BIN)/protovalidate-conformance $(ARGS) $(BIN)/protovalidate-conformance-go-cel --expected_failures=conformance/expected_failures.yaml .PHONY: conformance-hyperpb conformance-hyperpb: ## Run conformance tests against hyperpb @@ -98,6 +101,14 @@ bench: $(BENCH_TMP) -count $(BENCH_COUNT) \ | tee "$(BENCH_TMP)/$(BENCH_NAME).bench.txt" +.PHONY: bench-cel +bench-cel: $(BENCH_TMP) + go test -tags "cel_rules" -run ^$$ -bench="$(BENCH)" -benchmem \ + -memprofile "$(BENCH_TMP)/$(BENCH_NAME).mem.profile" \ + -cpuprofile "$(BENCH_TMP)/$(BENCH_NAME).cpu.profile" \ + -count $(BENCH_COUNT) \ + | tee "$(BENCH_TMP)/$(BENCH_NAME).bench.txt" + .PHONY: upgrade-go upgrade-go: @@ -127,3 +138,7 @@ $(BIN)/protovalidate-conformance: $(BIN) Makefile .PHONY: protovalidate-conformance-go protovalidate-conformance-go: $(BIN) GOBIN=$(abspath $(BIN)) $(GO) install ./internal/cmd/protovalidate-conformance-go + +.PHONY: protovalidate-conformance-go-cel +protovalidate-conformance-go-cel: $(BIN) + GOBIN=$(abspath $(BIN)) $(GO) build -o $(BIN)/protovalidate-conformance-go-cel -tags "cel_rules" ./internal/cmd/protovalidate-conformance-go diff --git a/README.md b/README.md index 315cfff0..b5d17f1e 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,17 @@ Highlights for Go developers include: API documentation for Go is available on [pkg.go.dev][pkg-go]. -### Experimental native support release -This release provides experimental native support for standard validation rule processing. It is disabled by default -and enabled by setting the environment variable `PV_NATIVE_RULES` to `true` at runtime. +### Native standard validation rules +This release provides native support for standard validation rule processing. They are enabled by default +and can be disabled by compiling with the build tag `cel_rules`: + +``` +go build -tags="cel_rules" ... +``` + +We continue to validate that the native rules and the CEL rules produce identical results. +The `compliance`, `test`, and `test-opaque` Makefile targets have been updated to run twice, +once with the native rules enabled, and once with the CEL rules enabled. Performance improvements on the included benchmarks: @@ -129,6 +137,8 @@ geomean ² ? ³ ratios must be >0 to compute geomean ``` +Use `make bench` to generate benchmarks with native rules and `make bench-cel` to generate benchmarks with CEL rules. + ## Additional languages and repositories Protovalidate isn't just for Go! You might be interested in sibling repositories for other languages: diff --git a/builder.go b/builder.go index 8bf36292..eb07e81a 100644 --- a/builder.go +++ b/builder.go @@ -18,9 +18,7 @@ import ( "cmp" "fmt" "maps" - "os" "slices" - "strings" "sync" "sync/atomic" @@ -452,6 +450,12 @@ func (bldr *builder) processWrapperRules( return nil } +// this flag will only be flipped by tests or by the init() function in disable_native_rules.go +// when the build tag cel_rules is set. +// +//nolint:gochecknoglobals // see the above comment +var useNativeRules = true + func (bldr *builder) processStandardRules( fdesc protoreflect.FieldDescriptor, rules *validate.FieldRules, @@ -469,7 +473,7 @@ func (bldr *builder) processStandardRules( // put behind a feature flag to allow for testing. // it's easier to follow like this, don't break it up //nolint:nestif - if f, _ := os.LookupEnv("PV_NATIVE_RULES"); strings.EqualFold(f, "true") { + if useNativeRules { // Try native Go evaluators for repeated list-level rules (min_items, max_items, unique). if fdesc.IsList() && valEval.NestedRule == nil { if native := tryNativeRepeatedRules(newBase(valEval), rules.GetRepeated()); native != nil { diff --git a/cel/library.go b/cel/library.go index 53899116..b9f262e8 100644 --- a/cel/library.go +++ b/cel/library.go @@ -16,15 +16,11 @@ package cel import ( "bytes" - "errors" "math" - "regexp" - "slices" - "strconv" "strings" "sync" - "unicode/utf8" + "buf.build/go/protovalidate/internal/rules" "github.com/google/cel-go/cel" "github.com/google/cel-go/common/overloads" "github.com/google/cel-go/common/types" @@ -36,11 +32,6 @@ import ( "google.golang.org/protobuf/types/dynamicpb" ) -var ( - // See https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address - emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") -) - // NewLibrary creates a new CEL library that specifies all of the functions and // settings required by protovalidate beyond the standard definitions of the CEL // Specification: @@ -161,7 +152,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(IsHostname(host)) + return types.Bool(rules.IsHostname(host)) }), ), ), @@ -175,7 +166,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(IsEmail(addr)) + return types.Bool(rules.IsEmail(addr)) }), ), ), @@ -189,7 +180,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(IsIP(addr, 0)) + return types.Bool(rules.IsIP(addr, 0)) }), ), cel.MemberOverload( @@ -202,7 +193,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !aok || !vok { return types.Bool(false) } - return types.Bool(IsIP(addr, vers)) + return types.Bool(rules.IsIP(addr, vers)) })), ), cel.Function("isIpPrefix", @@ -215,7 +206,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(IsIPPrefix(prefix, 0, false)) + return types.Bool(rules.IsIPPrefix(prefix, 0, false)) })), cel.MemberOverload( "string_int_is_ip_prefix_bool", @@ -227,7 +218,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !pok || !vok { return types.Bool(false) } - return types.Bool(IsIPPrefix(prefix, vers, false)) + return types.Bool(rules.IsIPPrefix(prefix, vers, false)) })), cel.MemberOverload( "string_bool_is_ip_prefix_bool", @@ -239,7 +230,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !pok || !sok { return types.Bool(false) } - return types.Bool(IsIPPrefix(prefix, 0, strict)) + return types.Bool(rules.IsIPPrefix(prefix, 0, strict)) })), cel.MemberOverload( "string_int_bool_is_ip_prefix_bool", @@ -252,7 +243,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !pok || !vok || !sok { return types.Bool(false) } - return types.Bool(IsIPPrefix(prefix, vers, strict)) + return types.Bool(rules.IsIPPrefix(prefix, vers, strict)) })), ), cel.Function("isUri", @@ -265,7 +256,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(IsURI(s)) + return types.Bool(rules.IsURI(s)) }), ), ), @@ -279,7 +270,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !ok { return types.Bool(false) } - return types.Bool(IsURIRef(s)) + return types.Bool(rules.IsURIRef(s)) }), ), ), @@ -379,7 +370,7 @@ func (l *library) CompileOptions() []cel.EnvOption { //nolint:funlen,gocyclo if !vok || !pok { return types.Bool(false) } - return types.Bool(IsHostAndPort(val, portReq)) + return types.Bool(rules.IsHostAndPort(val, portReq)) }), ), ), @@ -464,41 +455,6 @@ func (l *library) uniqueBytes(list traits.Lister) ref.Val { return types.Bool(true) } -// IsEmail reports whether val is an email address, for example "foo@example.com". -// -// Conforms to the definition for a valid email address from the HTML standard. -// Note that this standard willfully deviates from RFC 5322, which allows many -// unexpected forms of email addresses and will easily match a typographical -// error. -func IsEmail(val string) bool { - return emailRegex.MatchString(val) -} - -// IsURI reports whether val is a URI, for example "https://example.com/foo/bar?baz=quux#frag". -// -// URI is defined in the internet standard RFC 3986. -// Zone Identifiers in IPv6 address literals are supported (RFC 6874). -func IsURI(val string) bool { - uri := &uri{ - str: val, - } - return uri.uri() -} - -// IsURIRef reports whether val is a URI Reference - a URI such as -// "https://example.com/foo/bar?baz=quux#frag", or a Relative Reference such as -// "./foo/bar?query". -// -// URI, URI Reference, and Relative Reference are defined in the internet -// standard RFC 3986. Zone Identifiers in IPv6 address literals are supported -// (RFC 6874). -func IsURIRef(val string) bool { - uri := &uri{ - str: val, - } - return uri.uriReference() -} - // RequiredEnvOptions returns the options required to have expressions which // rely on the provided descriptor. func RequiredEnvOptions(fieldDesc protoreflect.FieldDescriptor) []cel.EnvOption { @@ -516,1251 +472,3 @@ func RequiredEnvOptions(fieldDesc protoreflect.FieldDescriptor) []cel.EnvOption } return nil } - -type ipv4 struct { - str string - index int - octets []uint8 - prefixLen int64 -} - -// getBits returns the 32-bit value of an address parsed through address() or addressPrefix(). -// Returns 0 if no address was parsed successfully. -func (i *ipv4) getBits() uint32 { - if len(i.octets) != 4 { - return 0 - } - return (uint32(i.octets[0]) << 24) | (uint32(i.octets[1]) << 16) | (uint32(i.octets[2]) << 8) | uint32(i.octets[3]) -} - -// isPrefixOnly returns true if all bits to the right of the prefix-length are -// all zeros. Behavior is undefined if addressPrefix() has not been called before, -// or has returned false. -func (i *ipv4) isPrefixOnly() bool { - bits := i.getBits() - var mask uint32 - if i.prefixLen == 32 { - mask = 0xffffffff - } else { - mask = ^(0xffffffff >> i.prefixLen) - } - masked := bits & mask - return bits == masked -} - -// address parses an IPv4 Address in dotted decimal notation. -func (i *ipv4) address() bool { - return i.addressPart() && i.index == len(i.str) -} - -// addressPrefix parses an IPv4 Address prefix. -func (i *ipv4) addressPrefix() bool { - return i.addressPart() && - i.take('/') && - i.prefixLength() && - i.index == len(i.str) -} - -// prefixLength parses the length of the prefix and stores the value in prefixLen. -func (i *ipv4) prefixLength() bool { - start := i.index - for i.digit() { - if i.index-start > 2 { - // max prefix-length is 32 bits, so anything more than 2 digits is invalid - return false - } - } - str := i.str[start:i.index] - if len(str) == 0 { - // too short - return false - } - if len(str) > 1 && str[0] == '0' { - // bad leading 0 - return false - } - value, err := strconv.ParseInt(str, 0, 32) - if err != nil { - // Error converting to number - return false - } - if value > 32 { - // max 32 bits - return false - } - i.prefixLen = value - return true -} - -// addressPart parses str from the current index to determine an address part. -func (i *ipv4) addressPart() bool { - start := i.index - if i.decOctet() && - i.take('.') && - i.decOctet() && - i.take('.') && - i.decOctet() && - i.take('.') && - i.decOctet() { - return true - } - i.index = start - return false -} - -// decOctet parses str from the current index to determine a decimal octet. -func (i *ipv4) decOctet() bool { - start := i.index - for i.digit() { - if i.index-start > 3 { - // decimal octet can be three characters at most - return false - } - } - str := i.str[start:i.index] - if len(str) == 0 { - // too short - return false - } - if len(str) > 1 && str[0] == '0' { - // bad leading 0 - return false - } - value, err := strconv.ParseInt(str, 10, 32) - if err != nil { - return false - } - if value > 255 { - return false - } - i.octets = append(i.octets, byte(value)) - return true -} - -// digit parses the rule: -// -// DIGIT = %x30-39 ; 0-9 -func (i *ipv4) digit() bool { - if i.index >= len(i.str) { - return false - } - c := i.str[i.index] - if '0' <= c && c <= '9' { - i.index++ - return true - } - return false -} - -// take reports whether the current position in the string is the character char. -func (i *ipv4) take(char byte) bool { - if i.index >= len(i.str) { - return false - } - if i.str[i.index] == char { - i.index++ - return true - } - return false -} - -// newIpv4 creates a new ipv4 based on str. -func newIpv4(str string) *ipv4 { - return &ipv4{ - str: str, - } -} - -type ipv6 struct { - str string - index int - pieces []uint16 // 16-bit pieces found - doubleColonAt int // number of 16-bit pieces found when double colon was found - doubleColonSeen bool - dottedRaw string // dotted notation for right-most 32 bits - dottedAddr *ipv4 // dotted notation successfully parsed as IPv4 - zoneIDFound bool - prefixLen int64 // 0 - 128 -} - -// getBits returns the 128-bit value of an address parsed through address() or -// addressPrefix(), as a 2-tuple of 64-bit values. -// Returns [0,0] if no address was parsed successfully. -func (i *ipv6) getBits() [2]uint64 { - p16 := i.pieces - // handle dotted decimal, add to p16 - if i.dottedAddr != nil { - dotted32 := i.dottedAddr.getBits() // right-most 32 bits - p16 = append(p16, uint16(dotted32>>16)) //nolint:gosec // this is ok, we only want the high 16 bits - p16 = append(p16, uint16(dotted32)) //nolint:gosec // this is ok, we only want the low 16 bits - } - // handle double colon, fill pieces with 0 - if i.doubleColonSeen { - for len(p16) < 8 { - // delete 0 entries at pos, insert a 0 - p16 = slices.Insert(p16, i.doubleColonAt, 0x00000000) - } - } - if len(p16) != 8 { - return [2]uint64{0, 0} - } - return [2]uint64{ - (uint64(p16[0]) << 48) | (uint64(p16[1]) << 32) | (uint64(p16[2]) << 16) | uint64(p16[3]), - (uint64(p16[4]) << 48) | (uint64(p16[5]) << 32) | (uint64(p16[6]) << 16) | uint64(p16[7]), - } -} - -// isPrefixOnly returns true if all bits to the right of the prefix-length are -// all zeros. Behavior is undefined if addressPrefix() has not been called before, -// or has returned false. -func (i *ipv6) isPrefixOnly() bool { - // For each 64-bit piece of the address, require that values to the right of the prefix are zero - for idx, p64 := range i.getBits() { - size := i.prefixLen - 64*int64(idx) - var mask uint64 - if size >= 64 { //nolint:gocritic - mask = 0xFFFFFFFFFFFFFFFF - } else if size < 0 { - mask = 0x0 - } else { - mask = ^(0xFFFFFFFFFFFFFFFF >> size) - } - masked := p64 & mask - if p64 != masked { - return false - } - } - return true -} - -// address parses an IPv6 Address following RFC 4291, with optional zone id following RFC 4007. -func (i *ipv6) address() bool { - return i.addressPart() && i.index == len(i.str) -} - -// addressPrefix parses an IPv6 Address Prefix following RFC 4291. Zone id is not permitted. -func (i *ipv6) addressPrefix() bool { - return i.addressPart() && - !i.zoneIDFound && - i.take('/') && - i.prefixLength() && - i.index == len(i.str) -} - -// prefixLength parses the length of the prefix and stores the value in prefixLen. -func (i *ipv6) prefixLength() bool { - start := i.index - for i.digit() { - if i.index-start > 3 { - return false - } - } - str := i.str[start:i.index] - if len(str) == 0 { - // too short - return false - } - if len(str) > 1 && str[0] == '0' { - // bad leading 0 - return false - } - value, err := strconv.ParseInt(str, 10, 32) - if err != nil { - return false - } - if value > 128 { - // max 128 bits - return false - } - i.prefixLen = value - return true -} - -// addressPart stores the dotted notation for right-most 32 bits in dottedRaw / dottedAddr if found. -func (i *ipv6) addressPart() bool { - for i.index < len(i.str) { - // dotted notation for right-most 32 bits, e.g. 0:0:0:0:0:ffff:192.1.56.10 - if (i.doubleColonSeen || len(i.pieces) == 6) && i.dotted() { - dotted := newIpv4(i.dottedRaw) - if dotted.address() { - i.dottedAddr = dotted - return true - } - return false - } - ok, err := i.h16() - if err != nil { - return false - } - if ok { - continue - } - if i.take(':') { //nolint:nestif - if i.take(':') { - if i.doubleColonSeen { - return false - } - i.doubleColonSeen = true - i.doubleColonAt = len(i.pieces) - if i.take(':') { - return false - } - } else if i.index == 1 || i.index == len(i.str) { - // invalid - string cannot start or end on single colon - return false - } - continue - } - if i.str[i.index] == '%' && !i.zoneID() { - return false - } - break - } - if i.doubleColonSeen { - return len(i.pieces) < 8 - } - return len(i.pieces) == 8 -} - -// zoneID parses the rule from RFC 6874: -// -// ZoneID = 1*( unreserved / pct-encoded ) -// -// There is no definition for the character set allowed in the zone -// identifier. RFC 4007 permits basically any non-null string. -func (i *ipv6) zoneID() bool { - start := i.index - if i.take('%') { - if len(i.str)-i.index > 0 { - // permit any non-null string - i.index = len(i.str) - i.zoneIDFound = true - return true - } - } - i.index = start - i.zoneIDFound = false - return false -} - -// dotted parses the rule: -// -// 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT -// -// Stores match in dottedRaw. -func (i *ipv6) dotted() bool { - start := i.index - i.dottedRaw = "" - for i.digit() || i.take('.') { - // Consume '*( DIGIT "." )' - } - if i.index-start >= 7 { - i.dottedRaw = i.str[start:i.index] - return true - } - i.index = start - return false -} - -// h16 parses the rule: -// -// h16 = 1*4HEXDIG -// -// If 1-4 hex digits are found, the parsed 16-bit unsigned integer is stored -// in pieces and true is returned. -// If 0 hex digits are found, returns false. -// If more than 4 hex digits are found, returns an error. -func (i *ipv6) h16() (bool, error) { - start := i.index - for i.hexdig() { - if i.index-start > 4 { - // too long - // this is an error condition, it means we found a string of more than - // four valid hex digits, which is invalid in ipv6 addresses. - return false, errors.New("invalid hex") - } - } - str := i.str[start:i.index] - if len(str) == 0 { - // too short, just return false - // this is not an error condition, it just means we didn't find any - // hex digits at the current position. - return false, nil - } - - value, err := strconv.ParseUint(str, 16, 16) - if err != nil { - // This is also an error condition. It means the parsed hextet we found - // cannot be converted into a number - return false, err - } - i.pieces = append(i.pieces, uint16(value)) - return true, nil -} - -// hexdig parses the rule: -// -// HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" -func (i *ipv6) hexdig() bool { - if i.index >= len(i.str) { - return false - } - c := i.str[i.index] - if ('0' <= c && c <= '9') || - ('a' <= c && c <= 'f') || - ('A' <= c && c <= 'F') { - i.index++ - return true - } - return false -} - -// digit parses the rule: -// -// DIGIT = %x30-39 ; 0-9 -func (i *ipv6) digit() bool { - if i.index >= len(i.str) { - return false - } - c := i.str[i.index] - if '0' <= c && c <= '9' { - i.index++ - return true - } - return false -} - -// take reports whether the current position in the string is the character char. -func (i *ipv6) take(char byte) bool { - if i.index >= len(i.str) { - return false - } - if i.str[i.index] == char { - i.index++ - return true - } - return false -} - -// newIpv6 creates a new ipv6 based on str. -func newIpv6(str string) *ipv6 { - return &ipv6{ - str: str, - doubleColonAt: -1, - } -} - -// IsIP returns true if the string is an IPv4 or IPv6 address, optionally limited to -// a specific version. -// -// Version 0 means either 4 or 6. Passing a version other than 0, 4, or 6 always -// returns false. -// -// IPv4 addresses are expected in the dotted decimal format, for example "192.168.5.21". -// IPv6 addresses are expected in their text representation, for example "::1", -// or "2001:0DB8:ABCD:0012::0". -// -// Both formats are well-defined in the internet standard RFC 3986. Zone -// identifiers for IPv6 addresses (for example "fe80::a%en1") are supported. -func IsIP(str string, version int64) bool { - if version == 6 { - return newIpv6(str).address() - } - if version == 4 { - return newIpv4(str).address() - } - if version == 0 { - return newIpv4(str).address() || newIpv6(str).address() - } - return false -} - -// IsIPPrefix returns true if the string is a valid IP with prefix length, optionally -// limited to a specific version (v4 or v6), and optionally requiring the host -// portion to be all zeros. -// -// An address prefix divides an IP address into a network portion, and a host -// portion. The prefix length specifies how many bits the network portion has. -// For example, the IPv6 prefix "2001:db8:abcd:0012::0/64" designates the -// left-most 64 bits as the network prefix. The range of the network is 2**64 -// addresses, from 2001:db8:abcd:0012::0 to 2001:db8:abcd:0012:ffff:ffff:ffff:ffff. -// -// An address prefix may include a specific host address, for example -// "2001:db8:abcd:0012::1f/64". With strict = true, this is not permitted. The -// host portion must be all zeros, as in "2001:db8:abcd:0012::0/64". -// -// The same principle applies to IPv4 addresses. "192.168.1.0/24" designates -// the first 24 bits of the 32-bit IPv4 as the network prefix. -func IsIPPrefix( - str string, - version int64, - strict bool, -) bool { - if version == 6 { - ip := newIpv6(str) - return ip.addressPrefix() && (!strict || ip.isPrefixOnly()) - } - if version == 4 { - ip := newIpv4(str) - return ip.addressPrefix() && (!strict || ip.isPrefixOnly()) - } - if version == 0 { - return IsIPPrefix(str, 6, strict) || IsIPPrefix(str, 4, strict) - } - return false -} - -// IsHostname returns true if the string is a valid hostname, for example "foo.example.com". -// -// A valid hostname follows the rules below: -// - The name consists of one or more labels, separated by a dot ("."). -// - Each label can be 1 to 63 alphanumeric characters. -// - A label can contain hyphens ("-"), but must not start or end with a hyphen. -// - The right-most label must not be digits only. -// - The name can have a trailing dot, for example "foo.example.com.". -// - The name can be 253 characters at most, excluding the optional trailing dot. -func IsHostname(val string) bool { - if len(val) > 253 { - return false - } - var str string - if strings.HasSuffix(val, ".") { - str = val[0 : len(val)-1] - } else { - str = val - } - - allDigits := false - - // split hostname on '.' and validate each part - for part := range strings.SplitSeq(str, ".") { - allDigits = true - // if part is empty, longer than 63 chars, or starts/ends with '-', it is invalid - l := len(part) - if l == 0 || l > 63 || strings.HasPrefix(part, "-") || strings.HasSuffix(part, "-") { - return false - } - // for each character in part - for i := range len(part) { - c := part[i] - // if the character is not a-z, A-Z, 0-9, or '-', it is invalid - if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' { - return false - } - allDigits = allDigits && c >= '0' && c <= '9' - } - } - // the last part cannot be all numbers - return !allDigits -} - -// IsHostAndPort returns true if the string is a valid host/port pair, for example -// "example.com:8080". -// -// If the argument portRequired is true, the port is required. If the argument -// is false, the port is optional. -// -// The host can be one of: -// - An IPv4 address in dotted decimal format, for example "192.168.0.1". -// - An IPv6 address enclosed in square brackets, for example "[::1]". -// - A hostname, for example "example.com". -// -// The port is separated by a colon. It must be non-empty, with a decimal number -// in the range of 0-65535, inclusive. -func IsHostAndPort(str string, portRequired bool) bool { - if len(str) == 0 { - return false - } - splitIdx := strings.LastIndex(str, ":") - if str[0] == '[' { - end := strings.LastIndex(str, "]") - switch end + 1 { - case len(str): // no port - return !portRequired && IsIP(str[1:end], 6) - case splitIdx: // port - return IsIP(str[1:end], 6) && isPort(str[splitIdx+1:]) - default: // malformed - return false - } - } - if splitIdx < 0 { - return !portRequired && (IsHostname(str) || IsIP(str, 4)) - } - host := str[0:splitIdx] - port := str[splitIdx+1:] - return (IsHostname(host) || IsIP(host, 4)) && isPort(port) -} - -// isPort returns true if the string is a valid port for IsHostAndPort. -func isPort(str string) bool { - if len(str) == 0 { - return false - } - for i := range len(str) { - c := str[i] - if '0' <= c && c <= '9' { - continue - } - return false - } - if len(str) > 1 && str[0] == '0' { - // bad leading 0 - return false - } - val, err := strconv.ParseUint(str, 0, 32) - if err != nil { - return false - } - return val <= 65535 -} - -type uri struct { - str string - index int - pctEncodedFound bool -} - -// uri parses the rule: -// -// URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] -func (u *uri) uri() bool { - start := u.index - if !(u.scheme() && u.take(':') && u.hierPart()) { - u.index = start - return false - } - if u.take('?') && !u.query() { - return false - } - if u.take('#') && !u.fragment() { - return false - } - if u.index != len(u.str) { - u.index = start - return false - } - return true -} - -// uriReference parses the rule: -// -// URI-reference = URI / relative-ref. -func (u *uri) uriReference() bool { - return u.uri() || u.relativeRef() -} - -// hierPart parses the rule: -// -// hier-part = "//" authority path-abempty. -// / path-absolute -// / path-rootless -// / path-empty. -func (u *uri) hierPart() bool { - start := u.index - if u.takeDoubleSlash() && - u.authority() && - u.pathAbempty() { - return true - } - u.index = start - return u.pathAbsolute() || u.pathRootless() || u.pathEmpty() -} - -// relativeRef parses the rule: -// -// relative-ref = relative-part [ "?" query ] [ "#" fragment ]. -func (u *uri) relativeRef() bool { - start := u.index - if !u.relativePart() { - return false - } - if u.take('?') && !u.query() { - u.index = start - return false - } - if u.take('#') && !u.fragment() { - u.index = start - return false - } - if u.index != len(u.str) { - u.index = start - return false - } - return true -} - -// relativePart parses the rule: -// -// relative-part = "//" authority path-abempty -// / path-absolute -// / path-noscheme -// / path-empty -func (u *uri) relativePart() bool { - start := u.index - if u.takeDoubleSlash() && - u.authority() && - u.pathAbempty() { - return true - } - u.index = start - return u.pathAbsolute() || u.pathNoscheme() || u.pathEmpty() -} - -// scheme parses the rule: -// -// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) -// -// Terminated by ":". -func (u *uri) scheme() bool { - start := u.index - if u.alpha() { - for u.alpha() || u.digit() || u.take('+') || u.take('-') || u.take('.') { - // Consume '*( ALPHA / DIGIT / "+" / "-" / "." )' - } - if u.peek(':') { - return true - } - } - u.index = start - return false -} - -// authority parses the rule: -// -// authority = [ userinfo "@" ] host [ ":" port ] -// -// Lead by double slash ("") and terminated by "/", "?", "#", or end of URI. -func (u *uri) authority() bool { - start := u.index - if u.userinfo() { - if !u.take('@') { - u.index = start - return false - } - } - if !u.host() { - u.index = start - return false - } - if u.take(':') { - if !u.port() { - u.index = start - return false - } - } - if !u.isAuthorityEnd() { - u.index = start - return false - } - return true -} - -// isAuthorityEnd reports whether the current position is the end of the authority. -// -// The authority component [...] is terminated by the next slash ("/"), -// question mark ("?"), or number sign ("#") character, or by the -// end of the URI. -func (u *uri) isAuthorityEnd() bool { - return u.index >= len(u.str) || - u.str[u.index] == '?' || - u.str[u.index] == '#' || - u.str[u.index] == '/' -} - -// userinfo parses the rule: -// -// userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) -// -// Terminated by "@" in authority. -func (u *uri) userinfo() bool { - start := u.index - for { - if u.unreserved() || - u.pctEncoded() || - u.subDelims() || - u.take(':') { - continue - } - if u.index < len(u.str) { - if u.str[u.index] == '@' { - return true - } - } - u.index = start - return false - } -} - -// checkHostPctEncoded verifies that str is correctly percent-encoded. -func (u *uri) checkHostPctEncoded(str string) bool { - unhex := func(char byte) byte { - switch { - case '0' <= char && char <= '9': - return char - '0' - case 'a' <= char && char <= 'f': - return char - 'a' + 10 - case 'A' <= char && char <= 'F': - return char - 'A' + 10 - } - return 0 - } - escaped := make([]byte, 0, len(str)) - for i := 0; i < len(str); { - switch str[i] { - case '%': - escaped = append(escaped, unhex(str[i+1])<<4|unhex(str[i+2])) - i += 3 - default: - escaped = append(escaped, str[i]) - i++ - } - } - return utf8.Valid(escaped) -} - -// host parses the rule: -// -// host = IP-literal / IPv4address / reg-name -func (u *uri) host() bool { - start := u.index - u.pctEncodedFound = false - // Note: IPv4address is a subset of reg-name - if (u.peek('[') && u.ipLiteral()) || u.regName() { - if u.pctEncodedFound { - rawHost := u.str[start:u.index] - // RFC 3986: - // > URI producing applications must not use percent-encoding in host - // > unless it is used to represent a UTF-8 character sequence. - if !u.checkHostPctEncoded(rawHost) { - return false - } - } - return true - } - return false -} - -// port parses the rule: -// -// port = *DIGIT -// -// Terminated by end of authority. -func (u *uri) port() bool { - start := u.index - for u.digit() { - // Consume '*DIGIT' - } - if u.isAuthorityEnd() { - return true - } - u.index = start - return false -} - -// ipLiteral parses the rule from RFC 6874: -// -// IP-literal = "[" ( IPv6address / IPv6addrz / IPvFuture ) "]" -func (u *uri) ipLiteral() bool { - start := u.index - if u.take('[') { - currIdx := u.index - if u.ipv6Address() && u.take(']') { - return true - } - u.index = currIdx - if u.ipv6addrz() && u.take(']') { - return true - } - u.index = currIdx - if u.ipvFuture() && u.take(']') { - return true - } - } - u.index = start - return false -} - -// ipv6Address parses the rule "IPv6address". -// -// Relies on the implementation of IsIP. -func (u *uri) ipv6Address() bool { - start := u.index - for u.hexdig() || u.take(':') { - // Consume '*( HEXDIG / ":" )' - } - if IsIP(u.str[start:u.index], 6) { - return true - } - u.index = start - return false -} - -// ipv6addrz parses the rule from RFC 6874: -// -// IPv6addrz = IPv6address "%25" ZoneID -func (u *uri) ipv6addrz() bool { - start := u.index - if u.ipv6Address() && - u.take('%') && - u.take('2') && - u.take('5') && - u.zoneID() { - return true - } - u.index = start - return false -} - -// zoneID parses the rule from RFC 6874: -// -// ZoneID = 1*( unreserved / pct-encoded ) -func (u *uri) zoneID() bool { - start := u.index - for u.unreserved() || u.pctEncoded() { - // Consume '*( unreserved / pct-encoded )' - } - if u.index-start > 0 { - return true - } - u.index = start - return false -} - -// ipvFuture parses the rule: -// -// IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) -func (u *uri) ipvFuture() bool { - start := u.index - if u.take('v') && u.hexdig() { - for u.hexdig() { - // Consume '*HEXDIG' - } - if u.take('.') { - counter := 0 - for u.unreserved() || u.subDelims() || u.take(':') { - counter++ - } - if counter >= 1 { - return true - } - } - } - u.index = start - return false -} - -// regName parses the rule: -// -// reg-name = *( unreserved / pct-encoded / sub-delims ) -// -// Terminates on start of port (":") or end of authority. -func (u *uri) regName() bool { - start := u.index - for { - if u.unreserved() || u.pctEncoded() || u.subDelims() { - continue - } - if u.isAuthorityEnd() { - // End of authority - return true - } - if u.str[u.index] == ':' { - return true - } - u.index = start - return false - } -} - -// isPathEnd reports whether the current position is the end of the path. -// -// The path is terminated by the first question mark ("?") or -// number sign ("#") character, or by the end of the URI. -func (u *uri) isPathEnd() bool { - return u.index >= len(u.str) || u.str[u.index] == '?' || u.str[u.index] == '#' -} - -// pathAbempty parses the rule: -// -// path-abempty = *( "/" segment ) -// -// Terminated by end of path: "?", "#", or end of URI. -func (u *uri) pathAbempty() bool { - start := u.index - for u.take('/') && u.segment() { - // Consume '*( "/" segment )' - } - if u.isPathEnd() { - return true - } - u.index = start - return false -} - -// pathAbsolute parses the rule: -// -// path-absolute = "/" [ segment-nz *( "/" segment ) ] -// -// Terminated by end of path: "?", "#", or end of URI. -func (u *uri) pathAbsolute() bool { - start := u.index - if u.take('/') { - if u.segmentNz() { - for u.take('/') && u.segment() { - // Consume '*( "/" segment )' - } - } - if u.isPathEnd() { - return true - } - } - u.index = start - return false -} - -// pathNoscheme parses the rule: -// -// path-noscheme = segment-nz-nc *( "/" segment ) -// -// Terminated by end of path: "?", "#", or end of URI. -func (u *uri) pathNoscheme() bool { - start := u.index - if u.segmentNzNc() { - for u.take('/') && u.segment() { - // Consume *( "/" segment ) - } - if u.isPathEnd() { - return true - } - } - u.index = start - return false -} - -// pathRootless parses the rule: -// -// path-rootless = segment-nz *( "/" segment ) -// -// Terminated by end of path: "?", "#", or end of URI. -func (u *uri) pathRootless() bool { - start := u.index - if u.segmentNz() { - for u.take('/') && u.segment() { - // Consume *( '/' segment ) - } - if u.isPathEnd() { - return true - } - } - u.index = start - return false -} - -// pathEmpty parses the rule: -// -// path-empty = 0 -// -// Terminated by end of path: "?", "#", or end of URI. -func (u *uri) pathEmpty() bool { - return u.isPathEnd() -} - -// segment parses the rule: -// -// segment = *pchar -func (u *uri) segment() bool { - for u.pchar() { - // Consume '*pchar' - } - return true -} - -// segmentNz parses the rule: -// -// segment-nz = 1*pchar -func (u *uri) segmentNz() bool { - start := u.index - if u.pchar() { - return u.segment() - } - u.index = start - return false -} - -// segmentNzNc parses the rule: -// -// segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" ) -// ; non-zero-length segment without any colon ":" -func (u *uri) segmentNzNc() bool { - start := u.index - for u.unreserved() || u.pctEncoded() || u.subDelims() || u.take('@') { - // Consume '*( unreserved / pct-encoded / sub-delims / "@" )' - } - if u.index-start > 0 { - return true - } - u.index = start - return false -} - -// pchar parses the rule: -// -// pchar = unreserved / pct-encoded / sub-delims / ":" / "@" -func (u *uri) pchar() bool { - return u.unreserved() || - u.pctEncoded() || - u.subDelims() || - u.take(':') || - u.take('@') -} - -// query parses the rule: -// -// query = *( pchar / "/" / "?" ) -// -// Terminated by "#" or end of URI. -func (u *uri) query() bool { - start := u.index - for { - if u.pchar() || u.take('/') || u.take('?') { - continue - } - if u.index == len(u.str) || u.str[u.index] == '#' { - return true - } - u.index = start - return false - } -} - -// fragment parses the rule: -// -// fragment = *( pchar / "/" / "?" ) -// -// Terminated by end of URI. -func (u *uri) fragment() bool { - start := u.index - for { - if u.pchar() || u.take('/') || u.take('?') { - continue - } - if u.index == len(u.str) { - return true - } - u.index = start - return false - } -} - -// pctEncoded parses the rule: -// -// pct-encoded = "%"+HEXDIG+HEXDIG -// -// Sets `pctEncodedFound` to true if a valid triplet was found. -func (u *uri) pctEncoded() bool { - start := u.index - if u.take('%') && u.hexdig() && u.hexdig() { - u.pctEncodedFound = true - return true - } - u.index = start - return false -} - -// unreserved parses the rule: -// -// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" -func (u *uri) unreserved() bool { - return u.alpha() || - u.digit() || - u.take('-') || - u.take('_') || - u.take('.') || - u.take('~') -} - -// subDelims parses the rule: -// -// sub-delims = "!" / "$" / "&" / "'" / "(" / ")" -// / "*" / "+" / "," / ";" / "=" -func (u *uri) subDelims() bool { - return u.take('!') || - u.take('$') || - u.take('&') || - u.take('\'') || - u.take('(') || - u.take(')') || - u.take('*') || - u.take('+') || - u.take(',') || - u.take(';') || - u.take('=') -} - -// alpha parses the rule: -// -// ALPHA = %x41-5A / %x61-7A ; A-Z / a-z -func (u *uri) alpha() bool { - if u.index >= len(u.str) { - return false - } - c := u.str[u.index] - if ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z') { - u.index++ - return true - } - return false -} - -// digit parses the rule: -// -// DIGIT = %x30-39 ; 0-9 -func (u *uri) digit() bool { - if u.index >= len(u.str) { - return false - } - c := u.str[u.index] - if '0' <= c && c <= '9' { - u.index++ - return true - } - return false -} - -// hexdig parses the rule: -// -// HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" -func (u *uri) hexdig() bool { - if u.index >= len(u.str) { - return false - } - c := u.str[u.index] - if ('0' <= c && c <= '9') || - ('a' <= c && c <= 'f') || - ('A' <= c && c <= 'F') { - u.index++ - return true - } - return false -} - -// take reports whether the current position in the string is the character char. -func (u *uri) take(char byte) bool { - if u.index >= len(u.str) { - return false - } - if u.str[u.index] == char { - u.index++ - return true - } - return false -} - -func (u *uri) takeDoubleSlash() bool { - first := u.take('/') - return first && u.take('/') -} - -func (u *uri) peek(char byte) bool { - return u.index < len(u.str) && u.str[u.index] == char -} diff --git a/cel/library_test.go b/cel/library_test.go index 9f623281..ea733558 100644 --- a/cel/library_test.go +++ b/cel/library_test.go @@ -257,21 +257,3 @@ func buildTestProgram(t *testing.T, env *cel.Env, expr string) cel.Program { require.NoError(t, err) return prog } - -func TestIsUri(t *testing.T) { - t.Parallel() - require.True(t, IsURI("A://")) -} - -func TestIsHostname(t *testing.T) { - t.Parallel() - require.True(t, IsHostname("foo.example.com")) - require.True(t, IsHostname("A.ISI.EDU")) - require.False(t, IsHostname("İ")) -} - -func TestIsHostAndPort(t *testing.T) { - t.Parallel() - require.False(t, IsHostAndPort("example.com:080", false)) - require.False(t, IsHostAndPort("example.com:00", false)) -} diff --git a/disable_native_rules.go b/disable_native_rules.go new file mode 100644 index 00000000..528be73e --- /dev/null +++ b/disable_native_rules.go @@ -0,0 +1,21 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build cel_rules + +package protovalidate + +func init() { + useNativeRules = false +} diff --git a/dynamic_end_to_end_test.go b/dynamic_end_to_end_test.go index 70b51919..43375b1a 100644 --- a/dynamic_end_to_end_test.go +++ b/dynamic_end_to_end_test.go @@ -15,6 +15,7 @@ package protovalidate import ( + "fmt" "math" "testing" @@ -28,6 +29,7 @@ import ( "google.golang.org/protobuf/types/dynamicpb" ) +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestDynamicRulesEndToEnd(t *testing.T) { data := []struct { name string @@ -139,15 +141,16 @@ func TestDynamicRulesEndToEnd(t *testing.T) { }) d.info.msgType = msgType // first with CEL rules - t.Setenv("PV_NATIVE_RULES", "false") + useNativeRules = false dynamicMessageTester(t, d.info, "value") // now with native rules to validate they produce identical results - t.Setenv("PV_NATIVE_RULES", "true") + useNativeRules = true dynamicMessageTester(t, d.info, "value") }) } } +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestDynamicRepeatedRulesEndToEnd(t *testing.T) { data := []struct { name string @@ -224,6 +227,7 @@ func TestDynamicRepeatedRulesEndToEnd(t *testing.T) { }, } for _, d := range data { + //nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules t.Run(d.name, func(t *testing.T) { // we are setting an environment variable to enable native rules, so we can't use parallel tests msgType := newDynamicMessageType(t, "test.native", "TestMessage", &descriptorpb.FieldDescriptorProto{ @@ -248,15 +252,16 @@ func TestDynamicRepeatedRulesEndToEnd(t *testing.T) { d.info.badValue = badList // first with CEL rules - t.Setenv("PV_NATIVE_RULES", "false") + useNativeRules = false dynamicMessageTester(t, d.info, "value") // now with native rules to validate they produce identical results - t.Setenv("PV_NATIVE_RULES", "true") + useNativeRules = true dynamicMessageTester(t, d.info, "value") }) } } +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeEnum_EndToEnd(t *testing.T) { // Build a proto with an enum field and const rule. enumDesc := &descriptorpb.EnumDescriptorProto{ @@ -309,15 +314,16 @@ func TestNativeEnum_EndToEnd(t *testing.T) { }) d.info.msgType = msgType // first with CEL rules - t.Setenv("PV_NATIVE_RULES", "false") + useNativeRules = false dynamicMessageTester(t, d.info, "value") // now with native rules to validate they produce identical results - t.Setenv("PV_NATIVE_RULES", "true") + useNativeRules = true dynamicMessageTester(t, d.info, "value") }) } } +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeMap_EndToEnd(t *testing.T) { data := []struct { name string @@ -352,6 +358,7 @@ func TestNativeMap_EndToEnd(t *testing.T) { }, } for _, d := range data { + //nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules t.Run(d.name, func(t *testing.T) { msgType := newDynamicMapMessageType(t, "test.native", "MapMsg", descriptorpb.FieldDescriptorProto_TYPE_STRING, @@ -379,10 +386,10 @@ func TestNativeMap_EndToEnd(t *testing.T) { d.info.badValue = maker(d.badMap) // first with CEL rules - t.Setenv("PV_NATIVE_RULES", "false") + useNativeRules = false dynamicMessageTester(t, d.info, "entries") // now with native rules to validate they produce identical results - t.Setenv("PV_NATIVE_RULES", "true") + useNativeRules = true dynamicMessageTester(t, d.info, "entries") }) } @@ -419,6 +426,8 @@ func dynamicMessageTester(t *testing.T, info dynamicMessageTesterInfo, fieldName // TestEnumCombinedRules validates that enum fields work correctly when // multiple rules are active: native const/in/not_in (via processStandardRules) // combined with native defined_only (via processEnumRules). +// +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestEnumCombinedRules(t *testing.T) { enumDesc := &descriptorpb.EnumDescriptorProto{ Name: proto.String("Status"), @@ -511,6 +520,7 @@ func TestEnumCombinedRules(t *testing.T) { } for _, tt := range tests { + //nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules t.Run(tt.name, func(t *testing.T) { msgType := newDynamicMessageTypeWithEnum(t, "test.combined", "EnumCombined", enumDesc, &descriptorpb.FieldDescriptorProto{ Name: proto.String("status"), @@ -522,9 +532,9 @@ func TestEnumCombinedRules(t *testing.T) { }) // Run with both CEL and native to verify identical results. - for _, mode := range []string{"false", "true"} { - t.Run("native="+mode, func(t *testing.T) { - t.Setenv("PV_NATIVE_RULES", mode) + for _, mode := range []bool{false, true} { + t.Run(fmt.Sprintf("native=%v", mode), func(t *testing.T) { + useNativeRules = mode validator, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) require.NoError(t, err) diff --git a/internal/rules/ipv4.go b/internal/rules/ipv4.go new file mode 100644 index 00000000..32e1faf6 --- /dev/null +++ b/internal/rules/ipv4.go @@ -0,0 +1,171 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rules + +import "strconv" + +// newIpv4 creates a new ipv4 based on str. +func newIpv4(str string) *ipv4 { + return &ipv4{ + str: str, + } +} + +type ipv4 struct { + str string + index int + octets []uint8 + prefixLen int64 +} + +// getBits returns the 32-bit value of an address parsed through address() or addressPrefix(). +// Returns 0 if no address was parsed successfully. +func (i *ipv4) getBits() uint32 { + if len(i.octets) != 4 { + return 0 + } + return (uint32(i.octets[0]) << 24) | (uint32(i.octets[1]) << 16) | (uint32(i.octets[2]) << 8) | uint32(i.octets[3]) +} + +// isPrefixOnly returns true if all bits to the right of the prefix-length are +// all zeros. Behavior is undefined if addressPrefix() has not been called before, +// or has returned false. +func (i *ipv4) isPrefixOnly() bool { + bits := i.getBits() + var mask uint32 + if i.prefixLen == 32 { + mask = 0xffffffff + } else { + mask = ^(0xffffffff >> i.prefixLen) + } + masked := bits & mask + return bits == masked +} + +// address parses an IPv4 Address in dotted decimal notation. +func (i *ipv4) address() bool { + return i.addressPart() && i.index == len(i.str) +} + +// addressPrefix parses an IPv4 Address prefix. +func (i *ipv4) addressPrefix() bool { + return i.addressPart() && + i.take('/') && + i.prefixLength() && + i.index == len(i.str) +} + +// prefixLength parses the length of the prefix and stores the value in prefixLen. +func (i *ipv4) prefixLength() bool { + start := i.index + for i.digit() { + if i.index-start > 2 { + // max prefix-length is 32 bits, so anything more than 2 digits is invalid + return false + } + } + str := i.str[start:i.index] + if len(str) == 0 { + // too short + return false + } + if len(str) > 1 && str[0] == '0' { + // bad leading 0 + return false + } + value, err := strconv.ParseInt(str, 0, 32) + if err != nil { + // Error converting to number + return false + } + if value > 32 { + // max 32 bits + return false + } + i.prefixLen = value + return true +} + +// addressPart parses str from the current index to determine an address part. +func (i *ipv4) addressPart() bool { + start := i.index + if i.decOctet() && + i.take('.') && + i.decOctet() && + i.take('.') && + i.decOctet() && + i.take('.') && + i.decOctet() { + return true + } + i.index = start + return false +} + +// decOctet parses str from the current index to determine a decimal octet. +func (i *ipv4) decOctet() bool { + start := i.index + for i.digit() { + if i.index-start > 3 { + // decimal octet can be three characters at most + return false + } + } + str := i.str[start:i.index] + if len(str) == 0 { + // too short + return false + } + if len(str) > 1 && str[0] == '0' { + // bad leading 0 + return false + } + value, err := strconv.ParseInt(str, 10, 32) + if err != nil { + return false + } + if value > 255 { + return false + } + i.octets = append(i.octets, byte(value)) + return true +} + +// digit parses the rule: +// +// DIGIT = %x30-39 ; 0-9 +func (i *ipv4) digit() bool { + if i.index >= len(i.str) { + return false + } + c := i.str[i.index] + if '0' <= c && c <= '9' { + i.index++ + return true + } + return false +} + +// take reports whether the current position in the string is the character char. +func (i *ipv4) take(char byte) bool { + if i.index >= len(i.str) { + return false + } + if i.str[i.index] == char { + i.index++ + return true + } + return false +} diff --git a/internal/rules/ipv6.go b/internal/rules/ipv6.go new file mode 100644 index 00000000..e14ff585 --- /dev/null +++ b/internal/rules/ipv6.go @@ -0,0 +1,301 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rules + +import ( + "errors" + "slices" + "strconv" +) + +// newIpv6 creates a new ipv6 based on str. +func newIpv6(str string) *ipv6 { + return &ipv6{ + str: str, + doubleColonAt: -1, + } +} + +type ipv6 struct { + str string + index int + pieces []uint16 // 16-bit pieces found + doubleColonAt int // number of 16-bit pieces found when double colon was found + doubleColonSeen bool + dottedRaw string // dotted notation for right-most 32 bits + dottedAddr *ipv4 // dotted notation successfully parsed as IPv4 + zoneIDFound bool + prefixLen int64 // 0 - 128 +} + +// getBits returns the 128-bit value of an address parsed through address() or +// addressPrefix(), as a 2-tuple of 64-bit values. +// Returns [0,0] if no address was parsed successfully. +func (i *ipv6) getBits() [2]uint64 { + p16 := i.pieces + // handle dotted decimal, add to p16 + if i.dottedAddr != nil { + dotted32 := i.dottedAddr.getBits() // right-most 32 bits + p16 = append(p16, uint16(dotted32>>16)) //nolint:gosec // this is ok, we only want the high 16 bits + p16 = append(p16, uint16(dotted32)) //nolint:gosec // this is ok, we only want the low 16 bits + } + // handle double colon, fill pieces with 0 + if i.doubleColonSeen { + for len(p16) < 8 { + // delete 0 entries at pos, insert a 0 + p16 = slices.Insert(p16, i.doubleColonAt, 0x00000000) + } + } + if len(p16) != 8 { + return [2]uint64{0, 0} + } + return [2]uint64{ + (uint64(p16[0]) << 48) | (uint64(p16[1]) << 32) | (uint64(p16[2]) << 16) | uint64(p16[3]), + (uint64(p16[4]) << 48) | (uint64(p16[5]) << 32) | (uint64(p16[6]) << 16) | uint64(p16[7]), + } +} + +// isPrefixOnly returns true if all bits to the right of the prefix-length are +// all zeros. Behavior is undefined if addressPrefix() has not been called before, +// or has returned false. +func (i *ipv6) isPrefixOnly() bool { + // For each 64-bit piece of the address, require that values to the right of the prefix are zero + for idx, p64 := range i.getBits() { + size := i.prefixLen - 64*int64(idx) + var mask uint64 + if size >= 64 { //nolint:gocritic + mask = 0xFFFFFFFFFFFFFFFF + } else if size < 0 { + mask = 0x0 + } else { + mask = ^(0xFFFFFFFFFFFFFFFF >> size) + } + masked := p64 & mask + if p64 != masked { + return false + } + } + return true +} + +// address parses an IPv6 Address following RFC 4291, with optional zone id following RFC 4007. +func (i *ipv6) address() bool { + return i.addressPart() && i.index == len(i.str) +} + +// addressPrefix parses an IPv6 Address Prefix following RFC 4291. Zone id is not permitted. +func (i *ipv6) addressPrefix() bool { + return i.addressPart() && + !i.zoneIDFound && + i.take('/') && + i.prefixLength() && + i.index == len(i.str) +} + +// prefixLength parses the length of the prefix and stores the value in prefixLen. +func (i *ipv6) prefixLength() bool { + start := i.index + for i.digit() { + if i.index-start > 3 { + return false + } + } + str := i.str[start:i.index] + if len(str) == 0 { + // too short + return false + } + if len(str) > 1 && str[0] == '0' { + // bad leading 0 + return false + } + value, err := strconv.ParseInt(str, 10, 32) + if err != nil { + return false + } + if value > 128 { + // max 128 bits + return false + } + i.prefixLen = value + return true +} + +// addressPart stores the dotted notation for right-most 32 bits in dottedRaw / dottedAddr if found. +func (i *ipv6) addressPart() bool { + for i.index < len(i.str) { + // dotted notation for right-most 32 bits, e.g. 0:0:0:0:0:ffff:192.1.56.10 + if (i.doubleColonSeen || len(i.pieces) == 6) && i.dotted() { + dotted := newIpv4(i.dottedRaw) + if dotted.address() { + i.dottedAddr = dotted + return true + } + return false + } + ok, err := i.h16() + if err != nil { + return false + } + if ok { + continue + } + if i.take(':') { //nolint:nestif + if i.take(':') { + if i.doubleColonSeen { + return false + } + i.doubleColonSeen = true + i.doubleColonAt = len(i.pieces) + if i.take(':') { + return false + } + } else if i.index == 1 || i.index == len(i.str) { + // invalid - string cannot start or end on single colon + return false + } + continue + } + if i.str[i.index] == '%' && !i.zoneID() { + return false + } + break + } + if i.doubleColonSeen { + return len(i.pieces) < 8 + } + return len(i.pieces) == 8 +} + +// zoneID parses the rule from RFC 6874: +// +// ZoneID = 1*( unreserved / pct-encoded ) +// +// There is no definition for the character set allowed in the zone +// identifier. RFC 4007 permits basically any non-null string. +func (i *ipv6) zoneID() bool { + start := i.index + if i.take('%') { + if len(i.str)-i.index > 0 { + // permit any non-null string + i.index = len(i.str) + i.zoneIDFound = true + return true + } + } + i.index = start + i.zoneIDFound = false + return false +} + +// dotted parses the rule: +// +// 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT +// +// Stores match in dottedRaw. +func (i *ipv6) dotted() bool { + start := i.index + i.dottedRaw = "" + //nolint:revive + for i.digit() || i.take('.') { + // Consume '*( DIGIT "." )' + } + if i.index-start >= 7 { + i.dottedRaw = i.str[start:i.index] + return true + } + i.index = start + return false +} + +// h16 parses the rule: +// +// h16 = 1*4HEXDIG +// +// If 1-4 hex digits are found, the parsed 16-bit unsigned integer is stored +// in pieces and true is returned. +// If 0 hex digits are found, returns false. +// If more than 4 hex digits are found, returns an error. +func (i *ipv6) h16() (bool, error) { + start := i.index + for i.hexdig() { + if i.index-start > 4 { + // too long + // this is an error condition, it means we found a string of more than + // four valid hex digits, which is invalid in ipv6 addresses. + return false, errors.New("invalid hex") + } + } + str := i.str[start:i.index] + if len(str) == 0 { + // too short, just return false + // this is not an error condition, it just means we didn't find any + // hex digits at the current position. + return false, nil + } + + value, err := strconv.ParseUint(str, 16, 16) + if err != nil { + // This is also an error condition. It means the parsed hextet we found + // cannot be converted into a number + return false, err + } + i.pieces = append(i.pieces, uint16(value)) + return true, nil +} + +// hexdig parses the rule: +// +// HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" +func (i *ipv6) hexdig() bool { + if i.index >= len(i.str) { + return false + } + c := i.str[i.index] + if ('0' <= c && c <= '9') || + ('a' <= c && c <= 'f') || + ('A' <= c && c <= 'F') { + i.index++ + return true + } + return false +} + +// digit parses the rule: +// +// DIGIT = %x30-39 ; 0-9 +func (i *ipv6) digit() bool { + if i.index >= len(i.str) { + return false + } + c := i.str[i.index] + if '0' <= c && c <= '9' { + i.index++ + return true + } + return false +} + +// take reports whether the current position in the string is the character char. +func (i *ipv6) take(char byte) bool { + if i.index >= len(i.str) { + return false + } + if i.str[i.index] == char { + i.index++ + return true + } + return false +} diff --git a/internal/rules/rules.go b/internal/rules/rules.go new file mode 100644 index 00000000..5736faa6 --- /dev/null +++ b/internal/rules/rules.go @@ -0,0 +1,225 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rules + +import ( + "regexp" + "strconv" + "strings" +) + +var ( + // See https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address + emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") +) + +// IsEmail reports whether val is an email address, for example "foo@example.com". +// +// Conforms to the definition for a valid email address from the HTML standard. +// Note that this standard willfully deviates from RFC 5322, which allows many +// unexpected forms of email addresses and will easily match a typographical +// error. +func IsEmail(val string) bool { + return emailRegex.MatchString(val) +} + +// IsURI reports whether val is a URI, for example "https://example.com/foo/bar?baz=quux#frag". +// +// URI is defined in the internet standard RFC 3986. +// Zone Identifiers in IPv6 address literals are supported (RFC 6874). +func IsURI(val string) bool { + uri := &uri{ + str: val, + } + return uri.uri() +} + +// IsURIRef reports whether val is a URI Reference - a URI such as +// "https://example.com/foo/bar?baz=quux#frag", or a Relative Reference such as +// "./foo/bar?query". +// +// URI, URI Reference, and Relative Reference are defined in the internet +// standard RFC 3986. Zone Identifiers in IPv6 address literals are supported +// (RFC 6874). +func IsURIRef(val string) bool { + uri := &uri{ + str: val, + } + return uri.uriReference() +} + +// IsIP returns true if the string is an IPv4 or IPv6 address, optionally limited to +// a specific version. +// +// Version 0 means either 4 or 6. Passing a version other than 0, 4, or 6 always +// returns false. +// +// IPv4 addresses are expected in the dotted decimal format, for example "192.168.5.21". +// IPv6 addresses are expected in their text representation, for example "::1", +// or "2001:0DB8:ABCD:0012::0". +// +// Both formats are well-defined in the internet standard RFC 3986. Zone +// identifiers for IPv6 addresses (for example "fe80::a%en1") are supported. +func IsIP(str string, version int64) bool { + if version == 6 { + return newIpv6(str).address() + } + if version == 4 { + return newIpv4(str).address() + } + if version == 0 { + return newIpv4(str).address() || newIpv6(str).address() + } + return false +} + +// IsIPPrefix returns true if the string is a valid IP with prefix length, optionally +// limited to a specific version (v4 or v6), and optionally requiring the host +// portion to be all zeros. +// +// An address prefix divides an IP address into a network portion, and a host +// portion. The prefix length specifies how many bits the network portion has. +// For example, the IPv6 prefix "2001:db8:abcd:0012::0/64" designates the +// left-most 64 bits as the network prefix. The range of the network is 2**64 +// addresses, from 2001:db8:abcd:0012::0 to 2001:db8:abcd:0012:ffff:ffff:ffff:ffff. +// +// An address prefix may include a specific host address, for example +// "2001:db8:abcd:0012::1f/64". With strict = true, this is not permitted. The +// host portion must be all zeros, as in "2001:db8:abcd:0012::0/64". +// +// The same principle applies to IPv4 addresses. "192.168.1.0/24" designates +// the first 24 bits of the 32-bit IPv4 as the network prefix. +func IsIPPrefix( + str string, + version int64, + strict bool, +) bool { + if version == 6 { + ip := newIpv6(str) + return ip.addressPrefix() && (!strict || ip.isPrefixOnly()) + } + if version == 4 { + ip := newIpv4(str) + return ip.addressPrefix() && (!strict || ip.isPrefixOnly()) + } + if version == 0 { + return IsIPPrefix(str, 6, strict) || IsIPPrefix(str, 4, strict) + } + return false +} + +// IsHostname returns true if the string is a valid hostname, for example "foo.example.com". +// +// A valid hostname follows the rules below: +// - The name consists of one or more labels, separated by a dot ("."). +// - Each label can be 1 to 63 alphanumeric characters. +// - A label can contain hyphens ("-"), but must not start or end with a hyphen. +// - The right-most label must not be digits only. +// - The name can have a trailing dot, for example "foo.example.com.". +// - The name can be 253 characters at most, excluding the optional trailing dot. +func IsHostname(val string) bool { + if len(val) > 253 { + return false + } + var str string + if strings.HasSuffix(val, ".") { + str = val[0 : len(val)-1] + } else { + str = val + } + + allDigits := false + + // split hostname on '.' and validate each part + for part := range strings.SplitSeq(str, ".") { + allDigits = true + // if part is empty, longer than 63 chars, or starts/ends with '-', it is invalid + l := len(part) + if l == 0 || l > 63 || strings.HasPrefix(part, "-") || strings.HasSuffix(part, "-") { + return false + } + // for each character in part + for i := range len(part) { + c := part[i] + // if the character is not a-z, A-Z, 0-9, or '-', it is invalid + if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') && (c < '0' || c > '9') && c != '-' { + return false + } + allDigits = allDigits && c >= '0' && c <= '9' + } + } + // the last part cannot be all numbers + return !allDigits +} + +// IsHostAndPort returns true if the string is a valid host/port pair, for example +// "example.com:8080". +// +// If the argument portRequired is true, the port is required. If the argument +// is false, the port is optional. +// +// The host can be one of: +// - An IPv4 address in dotted decimal format, for example "192.168.0.1". +// - An IPv6 address enclosed in square brackets, for example "[::1]". +// - A hostname, for example "example.com". +// +// The port is separated by a colon. It must be non-empty, with a decimal number +// in the range of 0-65535, inclusive. +func IsHostAndPort(str string, portRequired bool) bool { + if len(str) == 0 { + return false + } + splitIdx := strings.LastIndex(str, ":") + if str[0] == '[' { + end := strings.LastIndex(str, "]") + switch end + 1 { + case len(str): // no port + return !portRequired && IsIP(str[1:end], 6) + case splitIdx: // port + return IsIP(str[1:end], 6) && isPort(str[splitIdx+1:]) + default: // malformed + return false + } + } + if splitIdx < 0 { + return !portRequired && (IsHostname(str) || IsIP(str, 4)) + } + host := str[0:splitIdx] + port := str[splitIdx+1:] + return (IsHostname(host) || IsIP(host, 4)) && isPort(port) +} + +// isPort returns true if the string is a valid port for IsHostAndPort. +func isPort(str string) bool { + if len(str) == 0 { + return false + } + for i := range len(str) { + c := str[i] + if '0' <= c && c <= '9' { + continue + } + return false + } + if len(str) > 1 && str[0] == '0' { + // bad leading 0 + return false + } + val, err := strconv.ParseUint(str, 0, 32) + if err != nil { + return false + } + return val <= 65535 +} diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go new file mode 100644 index 00000000..e2dcf314 --- /dev/null +++ b/internal/rules/rules_test.go @@ -0,0 +1,39 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rules + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsUri(t *testing.T) { + t.Parallel() + require.True(t, IsURI("A://")) +} + +func TestIsHostname(t *testing.T) { + t.Parallel() + require.True(t, IsHostname("foo.example.com")) + require.True(t, IsHostname("A.ISI.EDU")) + require.False(t, IsHostname("İ")) +} + +func TestIsHostAndPort(t *testing.T) { + t.Parallel() + require.False(t, IsHostAndPort("example.com:080", false)) + require.False(t, IsHostAndPort("example.com:00", false)) +} diff --git a/internal/rules/uri.go b/internal/rules/uri.go new file mode 100644 index 00000000..c303ca3a --- /dev/null +++ b/internal/rules/uri.go @@ -0,0 +1,676 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rules + +import "unicode/utf8" + +type uri struct { + str string + index int + pctEncodedFound bool +} + +// uri parses the rule: +// +// URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] +func (u *uri) uri() bool { + start := u.index + if !(u.scheme() && u.take(':') && u.hierPart()) { + u.index = start + return false + } + if u.take('?') && !u.query() { + return false + } + if u.take('#') && !u.fragment() { + return false + } + if u.index != len(u.str) { + u.index = start + return false + } + return true +} + +// uriReference parses the rule: +// +// URI-reference = URI / relative-ref. +func (u *uri) uriReference() bool { + return u.uri() || u.relativeRef() +} + +// hierPart parses the rule: +// +// hier-part = "//" authority path-abempty. +// / path-absolute +// / path-rootless +// / path-empty. +func (u *uri) hierPart() bool { + start := u.index + if u.takeDoubleSlash() && + u.authority() && + u.pathAbempty() { + return true + } + u.index = start + return u.pathAbsolute() || u.pathRootless() || u.pathEmpty() +} + +// relativeRef parses the rule: +// +// relative-ref = relative-part [ "?" query ] [ "#" fragment ]. +func (u *uri) relativeRef() bool { + start := u.index + if !u.relativePart() { + return false + } + if u.take('?') && !u.query() { + u.index = start + return false + } + if u.take('#') && !u.fragment() { + u.index = start + return false + } + if u.index != len(u.str) { + u.index = start + return false + } + return true +} + +// relativePart parses the rule: +// +// relative-part = "//" authority path-abempty +// / path-absolute +// / path-noscheme +// / path-empty +func (u *uri) relativePart() bool { + start := u.index + if u.takeDoubleSlash() && + u.authority() && + u.pathAbempty() { + return true + } + u.index = start + return u.pathAbsolute() || u.pathNoscheme() || u.pathEmpty() +} + +// scheme parses the rule: +// +// scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) +// +// Terminated by ":". +func (u *uri) scheme() bool { + start := u.index + if u.alpha() { + //nolint:revive // this loop is intentionally empty to consume tokens + for u.alpha() || u.digit() || u.take('+') || u.take('-') || u.take('.') { + // Consume '*( ALPHA / DIGIT / "+" / "-" / "." )' + } + if u.peek(':') { + return true + } + } + u.index = start + return false +} + +// authority parses the rule: +// +// authority = [ userinfo "@" ] host [ ":" port ] +// +// Lead by double slash ("") and terminated by "/", "?", "#", or end of URI. +func (u *uri) authority() bool { + start := u.index + if u.userinfo() { + if !u.take('@') { + u.index = start + return false + } + } + if !u.host() { + u.index = start + return false + } + if u.take(':') { + if !u.port() { + u.index = start + return false + } + } + if !u.isAuthorityEnd() { + u.index = start + return false + } + return true +} + +// isAuthorityEnd reports whether the current position is the end of the authority. +// +// The authority component [...] is terminated by the next slash ("/"), +// question mark ("?"), or number sign ("#") character, or by the +// end of the URI. +func (u *uri) isAuthorityEnd() bool { + return u.index >= len(u.str) || + u.str[u.index] == '?' || + u.str[u.index] == '#' || + u.str[u.index] == '/' +} + +// userinfo parses the rule: +// +// userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) +// +// Terminated by "@" in authority. +func (u *uri) userinfo() bool { + start := u.index + for { + if u.unreserved() || + u.pctEncoded() || + u.subDelims() || + u.take(':') { + continue + } + if u.index < len(u.str) { + if u.str[u.index] == '@' { + return true + } + } + u.index = start + return false + } +} + +// checkHostPctEncoded verifies that str is correctly percent-encoded. +func (u *uri) checkHostPctEncoded(str string) bool { + unhex := func(char byte) byte { + switch { + case '0' <= char && char <= '9': + return char - '0' + case 'a' <= char && char <= 'f': + return char - 'a' + 10 + case 'A' <= char && char <= 'F': + return char - 'A' + 10 + } + return 0 + } + escaped := make([]byte, 0, len(str)) + for i := 0; i < len(str); { + switch str[i] { + case '%': + escaped = append(escaped, unhex(str[i+1])<<4|unhex(str[i+2])) + i += 3 + default: + escaped = append(escaped, str[i]) + i++ + } + } + return utf8.Valid(escaped) +} + +// host parses the rule: +// +// host = IP-literal / IPv4address / reg-name +func (u *uri) host() bool { + start := u.index + u.pctEncodedFound = false + // Note: IPv4address is a subset of reg-name + if (u.peek('[') && u.ipLiteral()) || u.regName() { + if u.pctEncodedFound { + rawHost := u.str[start:u.index] + // RFC 3986: + // > URI producing applications must not use percent-encoding in host + // > unless it is used to represent a UTF-8 character sequence. + if !u.checkHostPctEncoded(rawHost) { + return false + } + } + return true + } + return false +} + +// port parses the rule: +// +// port = *DIGIT +// +// Terminated by end of authority. +func (u *uri) port() bool { + start := u.index + for u.digit() { + // Consume '*DIGIT' + } + if u.isAuthorityEnd() { + return true + } + u.index = start + return false +} + +// ipLiteral parses the rule from RFC 6874: +// +// IP-literal = "[" ( IPv6address / IPv6addrz / IPvFuture ) "]" +func (u *uri) ipLiteral() bool { + start := u.index + if u.take('[') { + currIdx := u.index + if u.ipv6Address() && u.take(']') { + return true + } + u.index = currIdx + if u.ipv6addrz() && u.take(']') { + return true + } + u.index = currIdx + if u.ipvFuture() && u.take(']') { + return true + } + } + u.index = start + return false +} + +// ipv6Address parses the rule "IPv6address". +// +// Relies on the implementation of IsIP. +func (u *uri) ipv6Address() bool { + start := u.index + //nolint:revive // this loop is intentionally empty to consume tokens + for u.hexdig() || u.take(':') { + // Consume '*( HEXDIG / ":" )' + } + if IsIP(u.str[start:u.index], 6) { + return true + } + u.index = start + return false +} + +// ipv6addrz parses the rule from RFC 6874: +// +// IPv6addrz = IPv6address "%25" ZoneID +func (u *uri) ipv6addrz() bool { + start := u.index + if u.ipv6Address() && + u.take('%') && + u.take('2') && + u.take('5') && + u.zoneID() { + return true + } + u.index = start + return false +} + +// zoneID parses the rule from RFC 6874: +// +// ZoneID = 1*( unreserved / pct-encoded ) +func (u *uri) zoneID() bool { + start := u.index + //nolint:revive // this loop is intentionally empty to consume tokens + for u.unreserved() || u.pctEncoded() { + // Consume '*( unreserved / pct-encoded )' + } + if u.index-start > 0 { + return true + } + u.index = start + return false +} + +// ipvFuture parses the rule: +// +// IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) +func (u *uri) ipvFuture() bool { + start := u.index + if u.take('v') && u.hexdig() { + for u.hexdig() { + // Consume '*HEXDIG' + } + if u.take('.') { + counter := 0 + for u.unreserved() || u.subDelims() || u.take(':') { + counter++ + } + if counter >= 1 { + return true + } + } + } + u.index = start + return false +} + +// regName parses the rule: +// +// reg-name = *( unreserved / pct-encoded / sub-delims ) +// +// Terminates on start of port (":") or end of authority. +func (u *uri) regName() bool { + start := u.index + for { + if u.unreserved() || u.pctEncoded() || u.subDelims() { + continue + } + if u.isAuthorityEnd() { + // End of authority + return true + } + if u.str[u.index] == ':' { + return true + } + u.index = start + return false + } +} + +// isPathEnd reports whether the current position is the end of the path. +// +// The path is terminated by the first question mark ("?") or +// number sign ("#") character, or by the end of the URI. +func (u *uri) isPathEnd() bool { + return u.index >= len(u.str) || u.str[u.index] == '?' || u.str[u.index] == '#' +} + +// pathAbempty parses the rule: +// +// path-abempty = *( "/" segment ) +// +// Terminated by end of path: "?", "#", or end of URI. +func (u *uri) pathAbempty() bool { + start := u.index + //nolint:revive // this loop is intentionally empty to consume tokens + for u.take('/') && u.segment() { + // Consume '*( "/" segment )' + } + if u.isPathEnd() { + return true + } + u.index = start + return false +} + +// pathAbsolute parses the rule: +// +// path-absolute = "/" [ segment-nz *( "/" segment ) ] +// +// Terminated by end of path: "?", "#", or end of URI. +func (u *uri) pathAbsolute() bool { + start := u.index + if u.take('/') { + if u.segmentNz() { + //nolint:revive // this loop is intentionally empty to consume tokens + for u.take('/') && u.segment() { + // Consume '*( "/" segment )' + } + } + if u.isPathEnd() { + return true + } + } + u.index = start + return false +} + +// pathNoscheme parses the rule: +// +// path-noscheme = segment-nz-nc *( "/" segment ) +// +// Terminated by end of path: "?", "#", or end of URI. +func (u *uri) pathNoscheme() bool { + start := u.index + if u.segmentNzNc() { + //nolint:revive // this loop is intentionally empty to consume tokens + for u.take('/') && u.segment() { + // Consume *( "/" segment ) + } + if u.isPathEnd() { + return true + } + } + u.index = start + return false +} + +// pathRootless parses the rule: +// +// path-rootless = segment-nz *( "/" segment ) +// +// Terminated by end of path: "?", "#", or end of URI. +func (u *uri) pathRootless() bool { + start := u.index + if u.segmentNz() { + //nolint:revive // this loop is intentionally empty to consume tokens + for u.take('/') && u.segment() { + // Consume *( '/' segment ) + } + if u.isPathEnd() { + return true + } + } + u.index = start + return false +} + +// pathEmpty parses the rule: +// +// path-empty = 0 +// +// Terminated by end of path: "?", "#", or end of URI. +func (u *uri) pathEmpty() bool { + return u.isPathEnd() +} + +// segment parses the rule: +// +// segment = *pchar +func (u *uri) segment() bool { + for u.pchar() { + // Consume '*pchar' + } + return true +} + +// segmentNz parses the rule: +// +// segment-nz = 1*pchar +func (u *uri) segmentNz() bool { + start := u.index + if u.pchar() { + return u.segment() + } + u.index = start + return false +} + +// segmentNzNc parses the rule: +// +// segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" ) +// ; non-zero-length segment without any colon ":" +func (u *uri) segmentNzNc() bool { + start := u.index + //nolint:revive // this loop is intentionally empty to consume tokens + for u.unreserved() || u.pctEncoded() || u.subDelims() || u.take('@') { + // Consume '*( unreserved / pct-encoded / sub-delims / "@" )' + } + if u.index-start > 0 { + return true + } + u.index = start + return false +} + +// pchar parses the rule: +// +// pchar = unreserved / pct-encoded / sub-delims / ":" / "@" +func (u *uri) pchar() bool { + return u.unreserved() || + u.pctEncoded() || + u.subDelims() || + u.take(':') || + u.take('@') +} + +// query parses the rule: +// +// query = *( pchar / "/" / "?" ) +// +// Terminated by "#" or end of URI. +func (u *uri) query() bool { + start := u.index + for { + if u.pchar() || u.take('/') || u.take('?') { + continue + } + if u.index == len(u.str) || u.str[u.index] == '#' { + return true + } + u.index = start + return false + } +} + +// fragment parses the rule: +// +// fragment = *( pchar / "/" / "?" ) +// +// Terminated by end of URI. +func (u *uri) fragment() bool { + start := u.index + for { + if u.pchar() || u.take('/') || u.take('?') { + continue + } + if u.index == len(u.str) { + return true + } + u.index = start + return false + } +} + +// pctEncoded parses the rule: +// +// pct-encoded = "%"+HEXDIG+HEXDIG +// +// Sets `pctEncodedFound` to true if a valid triplet was found. +func (u *uri) pctEncoded() bool { + start := u.index + if u.take('%') && u.hexdig() && u.hexdig() { + u.pctEncodedFound = true + return true + } + u.index = start + return false +} + +// unreserved parses the rule: +// +// unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" +func (u *uri) unreserved() bool { + return u.alpha() || + u.digit() || + u.take('-') || + u.take('_') || + u.take('.') || + u.take('~') +} + +// subDelims parses the rule: +// +// sub-delims = "!" / "$" / "&" / "'" / "(" / ")" +// / "*" / "+" / "," / ";" / "=" +func (u *uri) subDelims() bool { + return u.take('!') || + u.take('$') || + u.take('&') || + u.take('\'') || + u.take('(') || + u.take(')') || + u.take('*') || + u.take('+') || + u.take(',') || + u.take(';') || + u.take('=') +} + +// alpha parses the rule: +// +// ALPHA = %x41-5A / %x61-7A ; A-Z / a-z +func (u *uri) alpha() bool { + if u.index >= len(u.str) { + return false + } + c := u.str[u.index] + if ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z') { + u.index++ + return true + } + return false +} + +// digit parses the rule: +// +// DIGIT = %x30-39 ; 0-9 +func (u *uri) digit() bool { + if u.index >= len(u.str) { + return false + } + c := u.str[u.index] + if '0' <= c && c <= '9' { + u.index++ + return true + } + return false +} + +// hexdig parses the rule: +// +// HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" +func (u *uri) hexdig() bool { + if u.index >= len(u.str) { + return false + } + c := u.str[u.index] + if ('0' <= c && c <= '9') || + ('a' <= c && c <= 'f') || + ('A' <= c && c <= 'F') { + u.index++ + return true + } + return false +} + +// take reports whether the current position in the string is the character char. +func (u *uri) take(char byte) bool { + if u.index >= len(u.str) { + return false + } + if u.str[u.index] == char { + u.index++ + return true + } + return false +} + +func (u *uri) takeDoubleSlash() bool { + first := u.take('/') + return first && u.take('/') +} + +func (u *uri) peek(char byte) bool { + return u.index < len(u.str) && u.str[u.index] == char +} diff --git a/native_bytes_test.go b/native_bytes_test.go index 00315013..7257e2ba 100644 --- a/native_bytes_test.go +++ b/native_bytes_test.go @@ -28,13 +28,14 @@ import ( "google.golang.org/protobuf/types/dynamicpb" ) +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeBytes(t *testing.T) { msg := examplev1.BenchTestBytes_builder{ B1: []byte{'\x32', '\x33'}, B: []byte{'\x03', '\x04'}, }.Build() - // with PV off - t.Setenv("PV_NATIVE_RULES", "false") + // with native rules off + useNativeRules = false val, err := New(WithMessages(msg), WithDisableLazy()) if err != nil { t.Fatalf("native off: expected no error, got %v", err) @@ -51,8 +52,8 @@ func TestNativeBytes(t *testing.T) { } } - // with PV on - t.Setenv("PV_NATIVE_RULES", "true") + // with native rules on + useNativeRules = true val, err = New(WithMessages(msg), WithDisableLazy()) if err != nil { t.Fatalf("native on: expected no error, got %v", err) @@ -254,8 +255,9 @@ func TestTryBuildNativeBytesRules_ReturnsNil(t *testing.T) { } } +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeBytes_EndToEnd(t *testing.T) { - t.Setenv("PV_NATIVE_RULES", "true") + useNativeRules = true msgType := newDynamicMessageType(t, "test.native", "BytesMsg", &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), diff --git a/native_selection_test.go b/native_selection_test.go index b5f733c7..133819ef 100644 --- a/native_selection_test.go +++ b/native_selection_test.go @@ -26,8 +26,10 @@ import ( "google.golang.org/protobuf/types/descriptorpb" ) -// TestNativeEvaluatorSelection verifies that when PV_NATIVE_RULES=true, +// TestNativeEvaluatorSelection verifies that when useNativeRules=true, // the builder selects native Go evaluators instead of CEL programs. +// +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeEvaluatorSelection(t *testing.T) { tests := []struct { name string @@ -129,9 +131,10 @@ func TestNativeEvaluatorSelection(t *testing.T) { } for _, tt := range tests { + //nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules t.Run(tt.name, func(t *testing.T) { - // With PV_NATIVE_RULES=true, should use native evaluator. - t.Setenv("PV_NATIVE_RULES", "true") + // With useNativeRules=true, should use native evaluator. + useNativeRules = true msgType := newDynamicMessageType(t, "test.sel", "Msg", &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), Number: proto.Int32(1), @@ -152,8 +155,10 @@ func TestNativeEvaluatorSelection(t *testing.T) { // TestNativeEvaluatorSelection_Enum tests enum separately since it needs // an enum descriptor in the file. +// +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeEvaluatorSelection_Enum(t *testing.T) { - t.Setenv("PV_NATIVE_RULES", "true") + useNativeRules = true enumDesc := &descriptorpb.EnumDescriptorProto{ Name: proto.String("TestEnum"), @@ -184,8 +189,10 @@ func TestNativeEvaluatorSelection_Enum(t *testing.T) { // TestNativeEvaluatorSelection_Map tests map separately since it needs // a map field descriptor. +// +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeEvaluatorSelection_Map(t *testing.T) { - t.Setenv("PV_NATIVE_RULES", "true") + useNativeRules = true msgType := newDynamicMapMessageType(t, "test.sel", "MapMsg", descriptorpb.FieldDescriptorProto_TYPE_STRING, @@ -204,10 +211,12 @@ func TestNativeEvaluatorSelection_Map(t *testing.T) { assertNoCELPrograms(t, evals) } -// TestCELFallbackWithoutNativeFlag verifies that without PV_NATIVE_RULES, +// TestCELFallbackWithoutNativeFlag verifies that without useNativeRules, // the builder uses CEL programs. +// +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestCELFallbackWithoutNativeFlag(t *testing.T) { - t.Setenv("PV_NATIVE_RULES", "false") + useNativeRules = false msgType := newDynamicMessageType(t, "test.sel", "CELMsg", &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), diff --git a/native_string.go b/native_string.go index d4a56513..8f84b9f2 100644 --- a/native_string.go +++ b/native_string.go @@ -23,7 +23,7 @@ import ( "unicode/utf8" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" - "buf.build/go/protovalidate/cel" + "buf.build/go/protovalidate/internal/rules" "google.golang.org/protobuf/reflect/protoreflect" ) @@ -139,7 +139,7 @@ var ( emptyRuleID: "string.email_empty", mainMsg: "must be a valid email address", emptyMsg: "value is empty, which is not a valid email address", - validate: cel.IsEmail, + validate: rules.IsEmail, } stringRuleHostname = stringWellKnownRule{ desc: strDescs.hostNameDesc, @@ -147,7 +147,7 @@ var ( emptyRuleID: "string.hostname_empty", mainMsg: "must be a valid hostname", emptyMsg: "value is empty, which is not a valid hostname", - validate: cel.IsHostname, + validate: rules.IsHostname, } stringRuleIP = stringWellKnownRule{ desc: strDescs.ipDesc, @@ -155,7 +155,7 @@ var ( emptyRuleID: "string.ip_empty", mainMsg: "must be a valid IP address", emptyMsg: "value is empty, which is not a valid IP address", - validate: func(s string) bool { return cel.IsIP(s, 0) }, + validate: func(s string) bool { return rules.IsIP(s, 0) }, } stringRuleIPv4 = stringWellKnownRule{ desc: strDescs.ipv4Desc, @@ -163,7 +163,7 @@ var ( emptyRuleID: "string.ipv4_empty", mainMsg: "must be a valid IPv4 address", emptyMsg: "value is empty, which is not a valid IPv4 address", - validate: func(s string) bool { return cel.IsIP(s, 4) }, + validate: func(s string) bool { return rules.IsIP(s, 4) }, } stringRuleIPv6 = stringWellKnownRule{ desc: strDescs.ipv6Desc, @@ -171,7 +171,7 @@ var ( emptyRuleID: "string.ipv6_empty", mainMsg: "must be a valid IPv6 address", emptyMsg: "value is empty, which is not a valid IPv6 address", - validate: func(s string) bool { return cel.IsIP(s, 6) }, + validate: func(s string) bool { return rules.IsIP(s, 6) }, } stringRuleURI = stringWellKnownRule{ desc: strDescs.uriDesc, @@ -179,13 +179,13 @@ var ( emptyRuleID: "string.uri_empty", mainMsg: "must be a valid URI", emptyMsg: "value is empty, which is not a valid URI", - validate: cel.IsURI, + validate: rules.IsURI, } stringRuleURIRef = stringWellKnownRule{ desc: strDescs.uriRefDesc, ruleID: "string.uri_ref", mainMsg: "must be a valid URI Reference", - validate: cel.IsURIRef, + validate: rules.IsURIRef, } stringRuleAddress = stringWellKnownRule{ desc: strDescs.addressDesc, @@ -193,7 +193,7 @@ var ( emptyRuleID: "string.address_empty", mainMsg: "must be a valid hostname, or ip address", emptyMsg: "value is empty, which is not a valid hostname, or ip address", - validate: func(s string) bool { return cel.IsHostname(s) || cel.IsIP(s, 0) }, + validate: func(s string) bool { return rules.IsHostname(s) || rules.IsIP(s, 0) }, } stringRuleUUID = stringWellKnownRule{ desc: strDescs.uuidDesc, @@ -217,7 +217,7 @@ var ( emptyRuleID: "string.ip_with_prefixlen_empty", mainMsg: "must be a valid IP prefix", emptyMsg: "value is empty, which is not a valid IP prefix", - validate: func(s string) bool { return cel.IsIPPrefix(s, 0, false) }, + validate: func(s string) bool { return rules.IsIPPrefix(s, 0, false) }, } stringRuleIPv4PrefixLen = stringWellKnownRule{ desc: strDescs.ipv4PrefixLenDesc, @@ -225,7 +225,7 @@ var ( emptyRuleID: "string.ipv4_with_prefixlen_empty", mainMsg: "must be a valid IPv4 address with prefix length", emptyMsg: "value is empty, which is not a valid IPv4 address with prefix length", - validate: func(s string) bool { return cel.IsIPPrefix(s, 4, false) }, + validate: func(s string) bool { return rules.IsIPPrefix(s, 4, false) }, } stringRuleIPv6PrefixLen = stringWellKnownRule{ desc: strDescs.ipv6PrefixLenDesc, @@ -233,7 +233,7 @@ var ( emptyRuleID: "string.ipv6_with_prefixlen_empty", mainMsg: "must be a valid IPv6 address with prefix length", emptyMsg: "value is empty, which is not a valid IPv6 address with prefix length", - validate: func(s string) bool { return cel.IsIPPrefix(s, 6, false) }, + validate: func(s string) bool { return rules.IsIPPrefix(s, 6, false) }, } stringRuleIPPrefix = stringWellKnownRule{ desc: strDescs.ipPrefixDesc, @@ -241,7 +241,7 @@ var ( emptyRuleID: "string.ip_prefix_empty", mainMsg: "must be a valid IP prefix", emptyMsg: "value is empty, which is not a valid IP prefix", - validate: func(s string) bool { return cel.IsIPPrefix(s, 0, true) }, + validate: func(s string) bool { return rules.IsIPPrefix(s, 0, true) }, } stringRuleIPv4Prefix = stringWellKnownRule{ desc: strDescs.ipv4PrefixDesc, @@ -249,7 +249,7 @@ var ( emptyRuleID: "string.ipv4_prefix_empty", mainMsg: "must be a valid IPv4 prefix", emptyMsg: "value is empty, which is not a valid IPv4 prefix", - validate: func(s string) bool { return cel.IsIPPrefix(s, 4, true) }, + validate: func(s string) bool { return rules.IsIPPrefix(s, 4, true) }, } stringRuleIPv6Prefix = stringWellKnownRule{ desc: strDescs.ipv6PrefixDesc, @@ -257,7 +257,7 @@ var ( emptyRuleID: "string.ipv6_prefix_empty", mainMsg: "must be a valid IPv6 prefix", emptyMsg: "value is empty, which is not a valid IPv6 prefix", - validate: func(s string) bool { return cel.IsIPPrefix(s, 6, true) }, + validate: func(s string) bool { return rules.IsIPPrefix(s, 6, true) }, } stringRuleHostAndPort = stringWellKnownRule{ desc: strDescs.hostAndPortDesc, @@ -265,7 +265,7 @@ var ( emptyRuleID: "string.host_and_port_empty", mainMsg: "must be a valid host (hostname or IP address) and port pair", emptyMsg: "value is empty, which is not a valid host and port pair", - validate: func(s string) bool { return cel.IsHostAndPort(s, true) }, + validate: func(s string) bool { return rules.IsHostAndPort(s, true) }, } stringRuleULID = stringWellKnownRule{ desc: strDescs.ulidDesc, From 173027e1ea567eea4316ad874c0b4d555bc315aa Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 22 Apr 2026 17:46:24 -0400 Subject: [PATCH 04/14] fix issues in native_map.go --- native_map.go | 83 ++++++++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/native_map.go b/native_map.go index 2d541dbb..a24ac854 100644 --- a/native_map.go +++ b/native_map.go @@ -16,6 +16,7 @@ package protovalidate import ( "fmt" + "math" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/reflect/protoreflect" @@ -27,43 +28,6 @@ var ( mapMaxPairsDesc = (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_pairs") ) -// nativeMapEval is a native Go evaluator for map-level rules -// (min_pairs, max_pairs). Key/value rules are handled separately -// by the kvPairs evaluator in map.go. -type nativeMapEval struct { - base - minPairs *uint64 - maxPairs *uint64 -} - -func (n nativeMapEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { - size := uint64(val.Map().Len()) //nolint:gosec - - // min_pairs - if n.minPairs != nil && size < *n.minPairs { - return n.newViolation(mapFieldRulesDesc, mapMinPairsDesc, - "map.min_pairs", - fmt.Sprintf("map must be at least %d entries", *n.minPairs), - val, protoreflect.ValueOfUint64(*n.minPairs)) - } - - // max_pairs - if n.maxPairs != nil && size > *n.maxPairs { - return n.newViolation(mapFieldRulesDesc, mapMaxPairsDesc, - "map.max_pairs", - fmt.Sprintf("map must be at most %d entries", *n.maxPairs), - val, protoreflect.ValueOfUint64(*n.maxPairs)) - } - - return nil -} - -func (n nativeMapEval) Tautology() bool { - return false -} - -var _ evaluator = nativeMapEval{} - // tryNativeMapRules attempts to build a native Go evaluator for // map-level rules (min_pairs, max_pairs). // Returns nil if the rules can't be handled natively. @@ -77,15 +41,15 @@ func tryNativeMapRules(base base, rules *validate.MapRules) evaluator { hasRule := false - var minPairs *uint64 + var minPairs uint64 if rules.HasMinPairs() { - minPairs = ptr(rules.GetMinPairs()) + minPairs = rules.GetMinPairs() hasRule = true } - var maxPairs *uint64 + var maxPairs uint64 = math.MaxUint64 if rules.HasMaxPairs() { - maxPairs = ptr(rules.GetMaxPairs()) + maxPairs = rules.GetMaxPairs() hasRule = true } @@ -99,3 +63,40 @@ func tryNativeMapRules(base base, rules *validate.MapRules) evaluator { maxPairs: maxPairs, } } + +var _ evaluator = nativeMapEval{} + +// nativeMapEval is a native Go evaluator for map-level rules +// (min_pairs, max_pairs). Key/value rules are handled separately +// by the kvPairs evaluator in map.go. +type nativeMapEval struct { + base + minPairs uint64 + maxPairs uint64 +} + +func (n nativeMapEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + size := uint64(val.Map().Len()) //nolint:gosec // int will never be negative or out of uint64 range + + // min_pairs + if size < n.minPairs { + return n.newViolation(mapFieldRulesDesc, mapMinPairsDesc, + "map.min_pairs", + fmt.Sprintf("map must be at least %d entries", n.minPairs), + val, protoreflect.ValueOfUint64(n.minPairs)) + } + + // max_pairs + if size > n.maxPairs { + return n.newViolation(mapFieldRulesDesc, mapMaxPairsDesc, + "map.max_pairs", + fmt.Sprintf("map must be at most %d entries", n.maxPairs), + val, protoreflect.ValueOfUint64(n.maxPairs)) + } + + return nil +} + +func (n nativeMapEval) Tautology() bool { + return n.minPairs == 0 && n.maxPairs == math.MaxUint64 +} From a442cab0b0dd9f5b883717768d2cbafcd1ef9cbb Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Wed, 22 Apr 2026 22:34:37 -0400 Subject: [PATCH 05/14] move tryNativeXXXRules to top of files. Make min and max tests not use pointers. Add test coverage for repeated bytes and strings. --- .../gen/tests/example/v1/native_test.pb.go | 36 ++- .../example/v1/native_test_protoopaque.pb.go | 44 ++- native_bool.go | 40 +-- native_bytes.go | 275 ++++++++--------- native_enum.go | 80 ++--- native_numeric.go | 174 +++++------ native_repeated.go | 109 +++---- native_repeated_test.go | 62 ++++ native_string.go | 286 +++++++++--------- proto/tests/example/v1/native_test.proto | 2 + 10 files changed, 617 insertions(+), 491 deletions(-) diff --git a/internal/gen/tests/example/v1/native_test.pb.go b/internal/gen/tests/example/v1/native_test.pb.go index dcc90686..f7c18029 100644 --- a/internal/gen/tests/example/v1/native_test.pb.go +++ b/internal/gen/tests/example/v1/native_test.pb.go @@ -432,6 +432,8 @@ func (b0 BenchTestBytes_builder) Build() *BenchTestBytes { type TestUnique struct { state protoimpl.MessageState `protogen:"hybrid.v1"` Enums []TestEnum `protobuf:"varint,1,rep,packed,name=enums,proto3,enum=tests.example.v1.TestEnum" json:"enums,omitempty"` + Bytes [][]byte `protobuf:"bytes,2,rep,name=bytes,proto3" json:"bytes,omitempty"` + Strings []string `protobuf:"bytes,3,rep,name=strings,proto3" json:"strings,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -468,14 +470,38 @@ func (x *TestUnique) GetEnums() []TestEnum { return nil } +func (x *TestUnique) GetBytes() [][]byte { + if x != nil { + return x.Bytes + } + return nil +} + +func (x *TestUnique) GetStrings() []string { + if x != nil { + return x.Strings + } + return nil +} + func (x *TestUnique) SetEnums(v []TestEnum) { x.Enums = v } +func (x *TestUnique) SetBytes(v [][]byte) { + x.Bytes = v +} + +func (x *TestUnique) SetStrings(v []string) { + x.Strings = v +} + type TestUnique_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - Enums []TestEnum + Enums []TestEnum + Bytes [][]byte + Strings []string } func (b0 TestUnique_builder) Build() *TestUnique { @@ -483,6 +509,8 @@ func (b0 TestUnique_builder) Build() *TestUnique { b, x := &b0, m0 _, _ = b, x x.Enums = b.Enums + x.Bytes = b.Bytes + x.Strings = b.Strings return m0 } @@ -695,10 +723,12 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\vnot_in_test\x18\x10 \x01(\x05B\x1f\xbaH\b\x1a\x068\x018\x038\x05\xca\xe66\x10\x12\x0e{intrange:4,4}R\tnotInTest\"\\\n" + "\x0eBenchTestBytes\x12%\n" + "\x02b1\x18\x01 \x01(\fB\x15\xbaH\x12z\x10\x10\x01\x18\x04J\x0223J\x0245J\x0267R\x02b1\x12#\n" + - "\x01b\x18\x04 \x01(\fB\x15\xbaH\x12z\x10\x10\x02\x18\x02B\x0223B\x0245B\x0267R\x01b\"H\n" + + "\x01b\x18\x04 \x01(\fB\x15\xbaH\x12z\x10\x10\x02\x18\x02B\x0223B\x0245B\x0267R\x01b\"\x8c\x01\n" + "\n" + "TestUnique\x12:\n" + - "\x05enums\x18\x01 \x03(\x0e2\x1a.tests.example.v1.TestEnumB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05enums\"\xe9\x01\n" + + "\x05enums\x18\x01 \x03(\x0e2\x1a.tests.example.v1.TestEnumB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05enums\x12\x1e\n" + + "\x05bytes\x18\x02 \x03(\fB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05bytes\x12\"\n" + + "\astrings\x18\x03 \x03(\tB\b\xbaH\x05\x92\x01\x02\x18\x01R\astrings\"\xe9\x01\n" + "\x10TestByteMatching\x126\n" + "\aip_addr\x18\x01 \x01(\fB\x1d\xbaH\x04z\x02P\x01\xca\xe66\x12\x12\x101234567890123456R\x06ipAddr\x12.\n" + "\tipv4_addr\x18\x02 \x01(\fB\x11\xbaH\x04z\x02X\x01\xca\xe66\x06\x12\x041234R\bipv4Addr\x12:\n" + diff --git a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go index 8696a56f..c076f5a4 100644 --- a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go +++ b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go @@ -430,10 +430,12 @@ func (b0 BenchTestBytes_builder) Build() *BenchTestBytes { } type TestUnique struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Enums []TestEnum `protobuf:"varint,1,rep,packed,name=enums,proto3,enum=tests.example.v1.TestEnum"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Enums []TestEnum `protobuf:"varint,1,rep,packed,name=enums,proto3,enum=tests.example.v1.TestEnum"` + xxx_hidden_Bytes [][]byte `protobuf:"bytes,2,rep,name=bytes,proto3"` + xxx_hidden_Strings []string `protobuf:"bytes,3,rep,name=strings,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *TestUnique) Reset() { @@ -468,14 +470,38 @@ func (x *TestUnique) GetEnums() []TestEnum { return nil } +func (x *TestUnique) GetBytes() [][]byte { + if x != nil { + return x.xxx_hidden_Bytes + } + return nil +} + +func (x *TestUnique) GetStrings() []string { + if x != nil { + return x.xxx_hidden_Strings + } + return nil +} + func (x *TestUnique) SetEnums(v []TestEnum) { x.xxx_hidden_Enums = v } +func (x *TestUnique) SetBytes(v [][]byte) { + x.xxx_hidden_Bytes = v +} + +func (x *TestUnique) SetStrings(v []string) { + x.xxx_hidden_Strings = v +} + type TestUnique_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. - Enums []TestEnum + Enums []TestEnum + Bytes [][]byte + Strings []string } func (b0 TestUnique_builder) Build() *TestUnique { @@ -483,6 +509,8 @@ func (b0 TestUnique_builder) Build() *TestUnique { b, x := &b0, m0 _, _ = b, x x.xxx_hidden_Enums = b.Enums + x.xxx_hidden_Bytes = b.Bytes + x.xxx_hidden_Strings = b.Strings return m0 } @@ -695,10 +723,12 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\vnot_in_test\x18\x10 \x01(\x05B\x1f\xbaH\b\x1a\x068\x018\x038\x05\xca\xe66\x10\x12\x0e{intrange:4,4}R\tnotInTest\"\\\n" + "\x0eBenchTestBytes\x12%\n" + "\x02b1\x18\x01 \x01(\fB\x15\xbaH\x12z\x10\x10\x01\x18\x04J\x0223J\x0245J\x0267R\x02b1\x12#\n" + - "\x01b\x18\x04 \x01(\fB\x15\xbaH\x12z\x10\x10\x02\x18\x02B\x0223B\x0245B\x0267R\x01b\"H\n" + + "\x01b\x18\x04 \x01(\fB\x15\xbaH\x12z\x10\x10\x02\x18\x02B\x0223B\x0245B\x0267R\x01b\"\x8c\x01\n" + "\n" + "TestUnique\x12:\n" + - "\x05enums\x18\x01 \x03(\x0e2\x1a.tests.example.v1.TestEnumB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05enums\"\xe9\x01\n" + + "\x05enums\x18\x01 \x03(\x0e2\x1a.tests.example.v1.TestEnumB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05enums\x12\x1e\n" + + "\x05bytes\x18\x02 \x03(\fB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05bytes\x12\"\n" + + "\astrings\x18\x03 \x03(\tB\b\xbaH\x05\x92\x01\x02\x18\x01R\astrings\"\xe9\x01\n" + "\x10TestByteMatching\x126\n" + "\aip_addr\x18\x01 \x01(\fB\x1d\xbaH\x04z\x02P\x01\xca\xe66\x12\x12\x101234567890123456R\x06ipAddr\x12.\n" + "\tipv4_addr\x18\x02 \x01(\fB\x11\xbaH\x04z\x02X\x01\xca\xe66\x06\x12\x041234R\bipv4Addr\x12:\n" + diff --git a/native_bool.go b/native_bool.go index 80521da7..c2bda5c8 100644 --- a/native_bool.go +++ b/native_bool.go @@ -27,6 +27,26 @@ var ( boolConstDesc = (*validate.BoolRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const") ) +// tryBuildNativeBoolRules attempts to build a native Go evaluator for +// bool rules. Returns nil if the rules can't be handled natively. +func tryBuildNativeBoolRules(base base, rules *validate.BoolRules) evaluator { + if rules == nil { + return nil + } + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + if !rules.HasConst() { + return nil + } + return nativeBoolEval{ + base: base, + constVal: rules.GetConst(), + } +} + +var _ evaluator = nativeBoolEval{} + // nativeBoolEval is a native Go evaluator for bool const rules. type nativeBoolEval struct { base @@ -45,23 +65,3 @@ func (n nativeBoolEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, func (n nativeBoolEval) Tautology() bool { return false } - -var _ evaluator = nativeBoolEval{} - -// tryBuildNativeBoolRules attempts to build a native Go evaluator for -// bool rules. Returns nil if the rules can't be handled natively. -func tryBuildNativeBoolRules(base base, rules *validate.BoolRules) evaluator { - if rules == nil { - return nil - } - if len(rules.ProtoReflect().GetUnknown()) > 0 { - return nil - } - if !rules.HasConst() { - return nil - } - return nativeBoolEval{ - base: base, - constVal: rules.GetConst(), - } -} diff --git a/native_bytes.go b/native_bytes.go index fe70d300..ea328e09 100644 --- a/native_bytes.go +++ b/native_bytes.go @@ -18,6 +18,7 @@ import ( "bytes" "errors" "fmt" + "math" "regexp" "slices" "strings" @@ -27,6 +28,133 @@ import ( "google.golang.org/protobuf/reflect/protoreflect" ) +// tryBuildNativeBytesRules attempts to build a native Go evaluator for +// bytes rules. Returns nil if the rules can't be handled natively. +func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { + if rules == nil { + return nil + } + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + // Detect well-known format constraint (ip, ipv4, ipv6, uuid). + // Check both presence and value — setting ip=false means no check. + var wellKnown *bytesWellKnown + switch { + case rules.GetIp(): + wellKnown = &bytesWellKnownIP + hasRule = true + case rules.GetIpv4(): + wellKnown = &bytesWellKnownIPv4 + hasRule = true + case rules.GetIpv6(): + wellKnown = &bytesWellKnownIPv6 + hasRule = true + case rules.GetUuid(): + wellKnown = &bytesWellKnownUUID + hasRule = true + } + + var constVal []byte + var hasConst bool + if rules.HasConst() { + constVal = rules.GetConst() + hasConst = true + hasRule = true + } + + var exactLen *uint64 + if rules.HasLen() { + exactLen = ptr(rules.GetLen()) + hasRule = true + } + + var minLen uint64 + if rules.HasMinLen() { + minLen = rules.GetMinLen() + hasRule = true + } + + var maxLen uint64 = math.MaxUint64 + if rules.HasMaxLen() { + maxLen = rules.GetMaxLen() + hasRule = true + } + + var compiledPattern *regexp.Regexp + var patternStr string + if rules.HasPattern() { + patternStr = rules.GetPattern() + var err error + compiledPattern, err = regexp.Compile(patternStr) + if err != nil { + return nil // bail to CEL + } + hasRule = true + } + + var prefix []byte + var hasPrefix bool + if rules.HasPrefix() { + prefix = rules.GetPrefix() + hasPrefix = true + hasRule = true + } + + var suffix []byte + var hasSuffix bool + if rules.HasSuffix() { + suffix = rules.GetSuffix() + hasSuffix = true + hasRule = true + } + + var contains []byte + var hasContains bool + if rules.HasContains() { + contains = rules.GetContains() + hasContains = true + hasRule = true + } + + var inVals [][]byte + if inVals = rules.GetIn(); len(inVals) > 0 { + hasRule = true + } + + var notInVals [][]byte + if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeBytesEval{ + base: base, + constVal: constVal, + hasConst: hasConst, + exactLen: exactLen, + minLen: minLen, + maxLen: maxLen, + pattern: compiledPattern, + patternStr: patternStr, + prefix: prefix, + hasPrefix: hasPrefix, + suffix: suffix, + hasSuffix: hasSuffix, + contains: contains, + hasContains: hasContains, + inVals: inVals, + notInVals: notInVals, + wellKnown: wellKnown, + } +} + // bytesWellKnown identifies which well-known bytes format constraint is active. type bytesWellKnown struct { desc protoreflect.FieldDescriptor @@ -117,14 +245,16 @@ func makeBytesDescriptors() bytesDescriptors { //nolint:gochecknoglobals var bytesDescs = makeBytesDescriptors() +var _ evaluator = nativeBytesEval{} + // nativeBytesEval is a native Go evaluator for bytes rules. type nativeBytesEval struct { base constVal []byte hasConst bool exactLen *uint64 - minLen *uint64 - maxLen *uint64 + minLen uint64 + maxLen uint64 pattern *regexp.Regexp patternStr string prefix []byte @@ -160,17 +290,17 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } // min_len - if n.minLen != nil && byteLen < *n.minLen { + if byteLen < n.minLen { return n.newViolation(bytesDescs.ruleDesc, bytesDescs.minLenDesc, - "bytes.min_len", fmt.Sprintf("must be at least %d bytes", *n.minLen), - val, protoreflect.ValueOfUint64(*n.minLen)) + "bytes.min_len", fmt.Sprintf("must be at least %d bytes", n.minLen), + val, protoreflect.ValueOfUint64(n.minLen)) } // max_len - if n.maxLen != nil && byteLen > *n.maxLen { + if byteLen > n.maxLen { return n.newViolation(bytesDescs.ruleDesc, bytesDescs.maxLenDesc, - "bytes.max_len", fmt.Sprintf("must be at most %d bytes", *n.maxLen), - val, protoreflect.ValueOfUint64(*n.maxLen)) + "bytes.max_len", fmt.Sprintf("must be at most %d bytes", n.maxLen), + val, protoreflect.ValueOfUint64(n.maxLen)) } // pattern (matches against string conversion of bytes) @@ -251,135 +381,6 @@ func (n nativeBytesEval) Tautology() bool { return false } -var _ evaluator = nativeBytesEval{} - -// tryBuildNativeBytesRules attempts to build a native Go evaluator for -// bytes rules. Returns nil if the rules can't be handled natively. -func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { - if rules == nil { - return nil - } - if len(rules.ProtoReflect().GetUnknown()) > 0 { - return nil - } - - hasRule := false - - // Detect well-known format constraint (ip, ipv4, ipv6, uuid). - // Check both presence and value — setting ip=false means no check. - var wellKnown *bytesWellKnown - switch { - case rules.GetIp(): - wellKnown = &bytesWellKnownIP - hasRule = true - case rules.GetIpv4(): - wellKnown = &bytesWellKnownIPv4 - hasRule = true - case rules.GetIpv6(): - wellKnown = &bytesWellKnownIPv6 - hasRule = true - case rules.GetUuid(): - wellKnown = &bytesWellKnownUUID - hasRule = true - } - - var constVal []byte - var hasConst bool - if rules.HasConst() { - constVal = rules.GetConst() - hasConst = true - hasRule = true - } - - var exactLen *uint64 - if rules.HasLen() { - exactLen = ptr(rules.GetLen()) - hasRule = true - } - - var minLen *uint64 - if rules.HasMinLen() { - minLen = ptr(rules.GetMinLen()) - hasRule = true - } - - var maxLen *uint64 - if rules.HasMaxLen() { - maxLen = ptr(rules.GetMaxLen()) - hasRule = true - } - - var compiledPattern *regexp.Regexp - var patternStr string - if rules.HasPattern() { - patternStr = rules.GetPattern() - var err error - compiledPattern, err = regexp.Compile(patternStr) - if err != nil { - return nil // bail to CEL - } - hasRule = true - } - - var prefix []byte - var hasPrefix bool - if rules.HasPrefix() { - prefix = rules.GetPrefix() - hasPrefix = true - hasRule = true - } - - var suffix []byte - var hasSuffix bool - if rules.HasSuffix() { - suffix = rules.GetSuffix() - hasSuffix = true - hasRule = true - } - - var contains []byte - var hasContains bool - if rules.HasContains() { - contains = rules.GetContains() - hasContains = true - hasRule = true - } - - var inVals [][]byte - if inVals = rules.GetIn(); len(inVals) > 0 { - hasRule = true - } - - var notInVals [][]byte - if notInVals = rules.GetNotIn(); len(notInVals) > 0 { - hasRule = true - } - - if !hasRule { - return nil - } - - return nativeBytesEval{ - base: base, - constVal: constVal, - hasConst: hasConst, - exactLen: exactLen, - minLen: minLen, - maxLen: maxLen, - pattern: compiledPattern, - patternStr: patternStr, - prefix: prefix, - hasPrefix: hasPrefix, - suffix: suffix, - hasSuffix: hasSuffix, - contains: contains, - hasContains: hasContains, - inVals: inVals, - notInVals: notInVals, - wellKnown: wellKnown, - } -} - // formatBytesList formats a [][]byte to match CEL's list formatting. func formatBytesList(vals [][]byte) string { parts := make([]string, len(vals)) diff --git a/native_enum.go b/native_enum.go index 2e58975a..1eb1996b 100644 --- a/native_enum.go +++ b/native_enum.go @@ -29,46 +29,6 @@ var ( enumNotInDesc = (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("not_in") ) -// nativeEnumEval is a native Go evaluator for enum const/in/not_in rules. -type nativeEnumEval struct { - base - constVal *int32 - inVals []int32 - notInVals []int32 -} - -func (n nativeEnumEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { - enumVal := int32(val.Enum()) - // const - if n.constVal != nil && enumVal != *n.constVal { - return n.newViolation(enumRuleDescriptor, enumConstDesc, - "enum.const", fmt.Sprintf("must equal %d", *n.constVal), - val, protoreflect.ValueOfInt32(*n.constVal)) - } - - // in - if len(n.inVals) > 0 && !slices.Contains(n.inVals, enumVal) { - return n.newViolation(enumRuleDescriptor, enumInDesc, - "enum.in", "must be in list "+formatList(n.inVals), - val, protoreflect.ValueOfInt32(enumVal)) - } - - // not_in - if len(n.notInVals) > 0 && slices.Contains(n.notInVals, enumVal) { - return n.newViolation(enumRuleDescriptor, enumNotInDesc, - "enum.not_in", "must not be in list "+formatList(n.notInVals), - val, protoreflect.ValueOfInt32(enumVal)) - } - - return nil -} - -func (n nativeEnumEval) Tautology() bool { - return false -} - -var _ evaluator = nativeEnumEval{} - // tryBuildNativeEnumRules attempts to build a native Go evaluator for // enum const/in/not_in rules. Returns nil if the rules can't be handled // natively. Note: defined_only is handled separately in enum.go. @@ -109,3 +69,43 @@ func tryBuildNativeEnumRules(base base, rules *validate.EnumRules) evaluator { notInVals: notInVals, } } + +var _ evaluator = nativeEnumEval{} + +// nativeEnumEval is a native Go evaluator for enum const/in/not_in rules. +type nativeEnumEval struct { + base + constVal *int32 + inVals []int32 + notInVals []int32 +} + +func (n nativeEnumEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { + enumVal := int32(val.Enum()) + // const + if n.constVal != nil && enumVal != *n.constVal { + return n.newViolation(enumRuleDescriptor, enumConstDesc, + "enum.const", fmt.Sprintf("must equal %d", *n.constVal), + val, protoreflect.ValueOfInt32(*n.constVal)) + } + + // in + if len(n.inVals) > 0 && !slices.Contains(n.inVals, enumVal) { + return n.newViolation(enumRuleDescriptor, enumInDesc, + "enum.in", "must be in list "+formatList(n.inVals), + val, protoreflect.ValueOfInt32(enumVal)) + } + + // not_in + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, enumVal) { + return n.newViolation(enumRuleDescriptor, enumNotInDesc, + "enum.not_in", "must not be in list "+formatList(n.notInVals), + val, protoreflect.ValueOfInt32(enumVal)) + } + + return nil +} + +func (n nativeEnumEval) Tautology() bool { + return false +} diff --git a/native_numeric.go b/native_numeric.go index a69546fc..db44bc85 100644 --- a/native_numeric.go +++ b/native_numeric.go @@ -29,6 +29,93 @@ type numericValue interface { ~int32 | ~int64 | ~uint32 | ~uint64 | ~float32 | ~float64 } +// Per-kind builder wrappers. Each handles nil check and type-specific +// concerns before delegating to the generic builder. + +func tryBuildNativeInt32Rules(base base, rules *validate.Int32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, int32Config) +} + +func tryBuildNativeSint32Rules(base base, rules *validate.SInt32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, sint32Config) +} + +func tryBuildNativeSfixed32Rules(base base, rules *validate.SFixed32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, sfixed32Config) +} + +func tryBuildNativeInt64Rules(base base, rules *validate.Int64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, int64Config) +} + +func tryBuildNativeSint64Rules(base base, rules *validate.SInt64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, sint64Config) +} + +func tryBuildNativeSfixed64Rules(base base, rules *validate.SFixed64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, sfixed64Config) +} + +func tryBuildNativeUint32Rules(base base, rules *validate.UInt32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, uint32Config) +} + +func tryBuildNativeFixed32Rules(base base, rules *validate.Fixed32Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, fixed32Config) +} + +func tryBuildNativeUint64Rules(base base, rules *validate.UInt64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, uint64Config) +} + +func tryBuildNativeFixed64Rules(base base, rules *validate.Fixed64Rules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, fixed64Config) +} + +func tryBuildNativeFloatRules(base base, rules *validate.FloatRules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, floatConfig) +} + +func tryBuildNativeDoubleRules(base base, rules *validate.DoubleRules) evaluator { + if rules == nil { + return nil + } + return tryBuildNativeNumericRules(base, rules, doubleConfig) +} + // numericRules is satisfied by all generated numeric rules types // (Int32Rules, Int64Rules, UInt32Rules, etc.). // @@ -477,93 +564,6 @@ func tryBuildNativeNumericRules[T numericValue, R numericRules[T]]( } } -// Per-kind builder wrappers. Each handles nil check and type-specific -// concerns before delegating to the generic builder. - -func tryBuildNativeInt32Rules(base base, rules *validate.Int32Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, int32Config) -} - -func tryBuildNativeSint32Rules(base base, rules *validate.SInt32Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, sint32Config) -} - -func tryBuildNativeSfixed32Rules(base base, rules *validate.SFixed32Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, sfixed32Config) -} - -func tryBuildNativeInt64Rules(base base, rules *validate.Int64Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, int64Config) -} - -func tryBuildNativeSint64Rules(base base, rules *validate.SInt64Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, sint64Config) -} - -func tryBuildNativeSfixed64Rules(base base, rules *validate.SFixed64Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, sfixed64Config) -} - -func tryBuildNativeUint32Rules(base base, rules *validate.UInt32Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, uint32Config) -} - -func tryBuildNativeFixed32Rules(base base, rules *validate.Fixed32Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, fixed32Config) -} - -func tryBuildNativeUint64Rules(base base, rules *validate.UInt64Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, uint64Config) -} - -func tryBuildNativeFixed64Rules(base base, rules *validate.Fixed64Rules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, fixed64Config) -} - -func tryBuildNativeFloatRules(base base, rules *validate.FloatRules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, floatConfig) -} - -func tryBuildNativeDoubleRules(base base, rules *validate.DoubleRules) evaluator { - if rules == nil { - return nil - } - return tryBuildNativeNumericRules(base, rules, doubleConfig) -} - func ptr[T any](v T) *T { return &v } // formatList formats a slice as "list [val1, val2]" to match CEL message format. diff --git a/native_repeated.go b/native_repeated.go index 7a41d8cf..5ad9c546 100644 --- a/native_repeated.go +++ b/native_repeated.go @@ -16,6 +16,7 @@ package protovalidate import ( "fmt" + "math" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" "google.golang.org/protobuf/reflect/protoreflect" @@ -28,34 +29,79 @@ var ( repeatedUniqueDesc = (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("unique") ) +// tryNativeRepeatedRules attempts to build a native Go evaluator for +// repeated list-level rules (min_items, max_items, unique). +// Returns nil if the rules can't be handled natively. +func tryNativeRepeatedRules(base base, rules *validate.RepeatedRules) evaluator { + if rules == nil { + return nil + } + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + var minItems uint64 + if rules.HasMinItems() { + minItems = rules.GetMinItems() + hasRule = true + } + + var maxItems uint64 = math.MaxUint64 + if rules.HasMaxItems() { + maxItems = rules.GetMaxItems() + hasRule = true + } + + unique := false + if rules.GetUnique() { + unique = true + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeRepeatedEval{ + base: base, + minItems: minItems, + maxItems: maxItems, + unique: unique, + } +} + +var _ evaluator = nativeRepeatedEval{} + // nativeRepeatedEval is a native Go evaluator for repeated list-level rules // (min_items, max_items, unique). Item-level rules are handled separately // by the listItems evaluator in repeated.go. type nativeRepeatedEval struct { base - minItems *uint64 - maxItems *uint64 + minItems uint64 + maxItems uint64 unique bool } func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { list := val.List() - size := uint64(list.Len()) //nolint:gosec + size := uint64(list.Len()) //nolint:gosec // len can't be < 0 and is always within uint64 range // min_items - if n.minItems != nil && size < *n.minItems { + if size < n.minItems { return n.newViolation(repeatedFieldRulesDesc, repeatedMinItemsDesc, "repeated.min_items", - fmt.Sprintf("must contain at least %d item(s)", *n.minItems), - val, protoreflect.ValueOfUint64(*n.minItems)) + fmt.Sprintf("must contain at least %d item(s)", n.minItems), + val, protoreflect.ValueOfUint64(n.minItems)) } // max_items - if n.maxItems != nil && size > *n.maxItems { + if size > n.maxItems { return n.newViolation(repeatedFieldRulesDesc, repeatedMaxItemsDesc, "repeated.max_items", - fmt.Sprintf("must contain no more than %d item(s)", *n.maxItems), - val, protoreflect.ValueOfUint64(*n.maxItems)) + fmt.Sprintf("must contain no more than %d item(s)", n.maxItems), + val, protoreflect.ValueOfUint64(n.maxItems)) } // unique @@ -136,48 +182,3 @@ func isUniqueBytes(list protoreflect.List, length int) bool { func (n nativeRepeatedEval) Tautology() bool { return false } - -var _ evaluator = nativeRepeatedEval{} - -// tryNativeRepeatedRules attempts to build a native Go evaluator for -// repeated list-level rules (min_items, max_items, unique). -// Returns nil if the rules can't be handled natively. -func tryNativeRepeatedRules(base base, rules *validate.RepeatedRules) evaluator { - if rules == nil { - return nil - } - if len(rules.ProtoReflect().GetUnknown()) > 0 { - return nil - } - - hasRule := false - - var minItems *uint64 - if rules.HasMinItems() { - minItems = ptr(rules.GetMinItems()) - hasRule = true - } - - var maxItems *uint64 - if rules.HasMaxItems() { - maxItems = ptr(rules.GetMaxItems()) - hasRule = true - } - - unique := false - if rules.GetUnique() { - unique = true - hasRule = true - } - - if !hasRule { - return nil - } - - return nativeRepeatedEval{ - base: base, - minItems: minItems, - maxItems: maxItems, - unique: unique, - } -} diff --git a/native_repeated_test.go b/native_repeated_test.go index b4b58e85..2471ddee 100644 --- a/native_repeated_test.go +++ b/native_repeated_test.go @@ -157,6 +157,68 @@ func TestNativeUniqueEnums(t *testing.T) { } } +func TestNativeUniqueStrings(t *testing.T) { + t.Parallel() + // should fail + { + msg := examplev1.TestUnique_builder{ + Strings: []string{ + "a", + "b", + "a", + }, + }.Build() + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())) + require.NoError(t, err) + require.Error(t, validator.Validate(msg)) + } + + // should pass + { + msg := examplev1.TestUnique_builder{ + Strings: []string{ + "a", + "b", + "c", + }, + }.Build() + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())) + require.NoError(t, err) + require.NoError(t, validator.Validate(msg)) + } +} + +func TestNativeUniqueBytes(t *testing.T) { + t.Parallel() + // should fail + { + msg := examplev1.TestUnique_builder{ + Bytes: [][]byte{ + []byte("a"), + []byte("b"), + []byte("a"), + }, + }.Build() + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())) + require.NoError(t, err) + require.Error(t, validator.Validate(msg)) + } + + // should pass + { + msg := examplev1.TestUnique_builder{ + Bytes: [][]byte{ + []byte("a"), + []byte("b"), + []byte("c"), + }, + }.Build() + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())) + require.NoError(t, err) + require.NoError(t, validator.Validate(msg)) + } +} + func TestNativeRepeatedTautology(t *testing.T) { t.Parallel() eval := buildNativeRepeated(t, validate.RepeatedRules_builder{MinItems: proto.Uint64(1)}.Build()) diff --git a/native_string.go b/native_string.go index 8f84b9f2..8a68bf53 100644 --- a/native_string.go +++ b/native_string.go @@ -27,6 +27,149 @@ import ( "google.golang.org/protobuf/reflect/protoreflect" ) +// tryBuildNativeStringRules attempts to build a native Go evaluator for +// string rules. Returns nil if the rules can't be handled natively. +func tryBuildNativeStringRules(base base, rules *validate.StringRules) evaluator { + if rules == nil { + return nil + } + + // Bail out for custom predefined extensions. + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + var wellKnownRule *stringWellKnownRule + var knownRegex validate.KnownRegex + var strict bool + + if rules.HasWellKnown() { + var err error + wellKnownRule, knownRegex, strict, err = parseStringWellKnown(rules) + if err != nil { + return nil + } + hasRule = true + } + + var constVal *string + if rules.HasConst() { + constVal = ptr(rules.GetConst()) + hasRule = true + } + + var exactLen *uint64 + if rules.HasLen() { + exactLen = ptr(rules.GetLen()) + hasRule = true + } + + var minLen *uint64 + if rules.HasMinLen() { + minLen = ptr(rules.GetMinLen()) + hasRule = true + } + + var maxLen *uint64 + if rules.HasMaxLen() { + maxLen = ptr(rules.GetMaxLen()) + hasRule = true + } + + var exactBytes *uint64 + if rules.HasLenBytes() { + exactBytes = ptr(rules.GetLenBytes()) + hasRule = true + } + + var minBytes *uint64 + if rules.HasMinBytes() { + minBytes = ptr(rules.GetMinBytes()) + hasRule = true + } + + var maxBytes *uint64 + if rules.HasMaxBytes() { + maxBytes = ptr(rules.GetMaxBytes()) + hasRule = true + } + + var compiledPattern *regexp.Regexp + var patternStr string + if rules.HasPattern() { + patternStr = rules.GetPattern() + var err error + compiledPattern, err = regexp.Compile(patternStr) + if err != nil { + // Invalid regex — bail to CEL which will also report a CompilationError. + return nil + } + hasRule = true + } + + var prefix *string + if rules.HasPrefix() { + prefix = ptr(rules.GetPrefix()) + hasRule = true + } + + var suffix *string + if rules.HasSuffix() { + suffix = ptr(rules.GetSuffix()) + hasRule = true + } + + var containsVal *string + if rules.HasContains() { + containsVal = ptr(rules.GetContains()) + hasRule = true + } + + var notContains *string + if rules.HasNotContains() { + notContains = ptr(rules.GetNotContains()) + hasRule = true + } + + var inVals []string + if inVals = rules.GetIn(); len(inVals) > 0 { + hasRule = true + } + + var notInVals []string + if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeStringEval{ + base: base, + constVal: constVal, + inVals: inVals, + notInVals: notInVals, + exactLen: exactLen, + minLen: minLen, + maxLen: maxLen, + exactBytes: exactBytes, + minBytes: minBytes, + maxBytes: maxBytes, + pattern: compiledPattern, + patternStr: patternStr, + prefix: prefix, + suffix: suffix, + contains: containsVal, + notContains: notContains, + wellKnownRule: wellKnownRule, + knownRegex: knownRegex, + strict: strict, + } +} + // stringDescriptors bundles the field descriptors for StringRules. type stringDescriptors struct { ruleDesc protoreflect.FieldDescriptor // FieldRules.string @@ -553,149 +696,6 @@ func parseStringWellKnown(rules *validate.StringRules) ( } } -// tryBuildNativeStringRules attempts to build a native Go evaluator for -// string rules. Returns nil if the rules can't be handled natively. -func tryBuildNativeStringRules(base base, rules *validate.StringRules) evaluator { - if rules == nil { - return nil - } - - // Bail out for custom predefined extensions. - if len(rules.ProtoReflect().GetUnknown()) > 0 { - return nil - } - - hasRule := false - - var wellKnownRule *stringWellKnownRule - var knownRegex validate.KnownRegex - var strict bool - - if rules.HasWellKnown() { - var err error - wellKnownRule, knownRegex, strict, err = parseStringWellKnown(rules) - if err != nil { - return nil - } - hasRule = true - } - - var constVal *string - if rules.HasConst() { - constVal = ptr(rules.GetConst()) - hasRule = true - } - - var exactLen *uint64 - if rules.HasLen() { - exactLen = ptr(rules.GetLen()) - hasRule = true - } - - var minLen *uint64 - if rules.HasMinLen() { - minLen = ptr(rules.GetMinLen()) - hasRule = true - } - - var maxLen *uint64 - if rules.HasMaxLen() { - maxLen = ptr(rules.GetMaxLen()) - hasRule = true - } - - var exactBytes *uint64 - if rules.HasLenBytes() { - exactBytes = ptr(rules.GetLenBytes()) - hasRule = true - } - - var minBytes *uint64 - if rules.HasMinBytes() { - minBytes = ptr(rules.GetMinBytes()) - hasRule = true - } - - var maxBytes *uint64 - if rules.HasMaxBytes() { - maxBytes = ptr(rules.GetMaxBytes()) - hasRule = true - } - - var compiledPattern *regexp.Regexp - var patternStr string - if rules.HasPattern() { - patternStr = rules.GetPattern() - var err error - compiledPattern, err = regexp.Compile(patternStr) - if err != nil { - // Invalid regex — bail to CEL which will also report a CompilationError. - return nil - } - hasRule = true - } - - var prefix *string - if rules.HasPrefix() { - prefix = ptr(rules.GetPrefix()) - hasRule = true - } - - var suffix *string - if rules.HasSuffix() { - suffix = ptr(rules.GetSuffix()) - hasRule = true - } - - var containsVal *string - if rules.HasContains() { - containsVal = ptr(rules.GetContains()) - hasRule = true - } - - var notContains *string - if rules.HasNotContains() { - notContains = ptr(rules.GetNotContains()) - hasRule = true - } - - var inVals []string - if inVals = rules.GetIn(); len(inVals) > 0 { - hasRule = true - } - - var notInVals []string - if notInVals = rules.GetNotIn(); len(notInVals) > 0 { - hasRule = true - } - - if !hasRule { - return nil - } - - return nativeStringEval{ - base: base, - constVal: constVal, - inVals: inVals, - notInVals: notInVals, - exactLen: exactLen, - minLen: minLen, - maxLen: maxLen, - exactBytes: exactBytes, - minBytes: minBytes, - maxBytes: maxBytes, - pattern: compiledPattern, - patternStr: patternStr, - prefix: prefix, - suffix: suffix, - contains: containsVal, - notContains: notContains, - wellKnownRule: wellKnownRule, - knownRegex: knownRegex, - strict: strict, - } -} - // formatStringList formats a []string as [a, b] to match CEL's // string list formatting (no quoting around elements). func formatStringList(vals []string) string { diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto index 6836943a..6d56eddb 100644 --- a/proto/tests/example/v1/native_test.proto +++ b/proto/tests/example/v1/native_test.proto @@ -137,6 +137,8 @@ enum TestEnum { message TestUnique { repeated TestEnum enums = 1 [(buf.validate.field).repeated.unique = true]; + repeated bytes bytes = 2 [(buf.validate.field).repeated.unique = true]; + repeated string strings = 3 [(buf.validate.field).repeated.unique = true]; } message TestByteMatching { From 0a9cf5b15e4e89ae027af420a88a86ed3b24d69f Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 23 Apr 2026 12:24:35 -0400 Subject: [PATCH 06/14] refactor code so that we can support multiple violations in a single error. --- base.go | 8 +- .../gen/tests/example/v1/native_test.pb.go | 82 ++++- .../example/v1/native_test_protoopaque.pb.go | 82 ++++- native_bool.go | 5 +- native_bytes.go | 197 +++++++---- native_bytes_test.go | 30 ++ native_enum.go | 66 +++- native_map.go | 10 +- native_numeric.go | 330 ++++++++++-------- native_numeric_test.go | 2 +- native_repeated.go | 72 ++-- native_string.go | 240 +++++++------ proto/tests/example/v1/native_test.proto | 8 + 13 files changed, 747 insertions(+), 385 deletions(-) diff --git a/base.go b/base.go index c1b225ef..2a79c4b7 100644 --- a/base.go +++ b/base.go @@ -61,7 +61,7 @@ func (b *base) rulePath(suffix *validate.FieldPath) *validate.FieldPath { return prefixRulePath(b.RulePrefix, suffix) } -// newViolation constructs a ValidationError with a single violation. +// newViolation constructs a Violation. // ruleDesc is the top-level rule descriptor (e.g., FieldRules.int32), // desc is the specific constraint descriptor (e.g., Int32Rules.gt). func (b *base) newViolation( @@ -71,8 +71,8 @@ func (b *base) newViolation( message string, fieldValue protoreflect.Value, ruleValue protoreflect.Value, -) error { - return &ValidationError{Violations: []*Violation{{ +) *Violation { + return &Violation{ Proto: validate.Violation_builder{ Field: b.fieldPath(), Rule: b.rulePath(validate.FieldPath_builder{ @@ -88,7 +88,7 @@ func (b *base) newViolation( FieldDescriptor: b.Descriptor, RuleValue: ruleValue, RuleDescriptor: desc, - }}} + } } func prefixRulePath(prefix *validate.FieldPath, suffix *validate.FieldPath) *validate.FieldPath { diff --git a/internal/gen/tests/example/v1/native_test.pb.go b/internal/gen/tests/example/v1/native_test.pb.go index f7c18029..8090d234 100644 --- a/internal/gen/tests/example/v1/native_test.pb.go +++ b/internal/gen/tests/example/v1/native_test.pb.go @@ -514,6 +514,66 @@ func (b0 TestUnique_builder) Build() *TestUnique { return m0 } +type TestByteBroken struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Broken []byte `protobuf:"bytes,1,opt,name=broken,proto3" json:"broken,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestByteBroken) Reset() { + *x = TestByteBroken{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestByteBroken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestByteBroken) ProtoMessage() {} + +func (x *TestByteBroken) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *TestByteBroken) GetBroken() []byte { + if x != nil { + return x.Broken + } + return nil +} + +func (x *TestByteBroken) SetBroken(v []byte) { + if v == nil { + v = []byte{} + } + x.Broken = v +} + +type TestByteBroken_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Broken []byte +} + +func (b0 TestByteBroken_builder) Build() *TestByteBroken { + m0 := &TestByteBroken{} + b, x := &b0, m0 + _, _ = b, x + x.Broken = b.Broken + return m0 +} + type TestByteMatching struct { state protoimpl.MessageState `protogen:"hybrid.v1"` IpAddr []byte `protobuf:"bytes,1,opt,name=ip_addr,json=ipAddr,proto3" json:"ip_addr,omitempty"` @@ -526,7 +586,7 @@ type TestByteMatching struct { func (x *TestByteMatching) Reset() { *x = TestByteMatching{} - mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + mi := &file_tests_example_v1_native_test_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -538,7 +598,7 @@ func (x *TestByteMatching) String() string { func (*TestByteMatching) ProtoMessage() {} func (x *TestByteMatching) ProtoReflect() protoreflect.Message { - mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + mi := &file_tests_example_v1_native_test_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -635,7 +695,7 @@ type StringMatching struct { func (x *StringMatching) Reset() { *x = StringMatching{} - mi := &file_tests_example_v1_native_test_proto_msgTypes[4] + mi := &file_tests_example_v1_native_test_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -647,7 +707,7 @@ func (x *StringMatching) String() string { func (*StringMatching) ProtoMessage() {} func (x *StringMatching) ProtoReflect() protoreflect.Message { - mi := &file_tests_example_v1_native_test_proto_msgTypes[4] + mi := &file_tests_example_v1_native_test_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -728,7 +788,10 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "TestUnique\x12:\n" + "\x05enums\x18\x01 \x03(\x0e2\x1a.tests.example.v1.TestEnumB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05enums\x12\x1e\n" + "\x05bytes\x18\x02 \x03(\fB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05bytes\x12\"\n" + - "\astrings\x18\x03 \x03(\tB\b\xbaH\x05\x92\x01\x02\x18\x01R\astrings\"\xe9\x01\n" + + "\astrings\x18\x03 \x03(\tB\b\xbaH\x05\x92\x01\x02\x18\x01R\astrings\"5\n" + + "\x0eTestByteBroken\x12#\n" + + "\x06broken\x18\x01 \x01(\fB\v\xbaH\bz\x06\x10\x05\x18\n" + + "x\x01R\x06broken\"\xe9\x01\n" + "\x10TestByteMatching\x126\n" + "\aip_addr\x18\x01 \x01(\fB\x1d\xbaH\x04z\x02P\x01\xca\xe66\x12\x12\x101234567890123456R\x06ipAddr\x12.\n" + "\tipv4_addr\x18\x02 \x01(\fB\x11\xbaH\x04z\x02X\x01\xca\xe66\x06\x12\x041234R\bipv4Addr\x12:\n" + @@ -745,14 +808,15 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\x14com.tests.example.v1B\x0fNativeTestProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" var file_tests_example_v1_native_test_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_tests_example_v1_native_test_proto_goTypes = []any{ (TestEnum)(0), // 0: tests.example.v1.TestEnum (*BenchGT)(nil), // 1: tests.example.v1.BenchGT (*BenchTestBytes)(nil), // 2: tests.example.v1.BenchTestBytes (*TestUnique)(nil), // 3: tests.example.v1.TestUnique - (*TestByteMatching)(nil), // 4: tests.example.v1.TestByteMatching - (*StringMatching)(nil), // 5: tests.example.v1.StringMatching + (*TestByteBroken)(nil), // 4: tests.example.v1.TestByteBroken + (*TestByteMatching)(nil), // 5: tests.example.v1.TestByteMatching + (*StringMatching)(nil), // 6: tests.example.v1.StringMatching } var file_tests_example_v1_native_test_proto_depIdxs = []int32{ 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum @@ -774,7 +838,7 @@ func file_tests_example_v1_native_test_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_native_test_proto_rawDesc), len(file_tests_example_v1_native_test_proto_rawDesc)), NumEnums: 1, - NumMessages: 5, + NumMessages: 6, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go index c076f5a4..1aa80822 100644 --- a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go +++ b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go @@ -514,6 +514,66 @@ func (b0 TestUnique_builder) Build() *TestUnique { return m0 } +type TestByteBroken struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Broken []byte `protobuf:"bytes,1,opt,name=broken,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TestByteBroken) Reset() { + *x = TestByteBroken{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TestByteBroken) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TestByteBroken) ProtoMessage() {} + +func (x *TestByteBroken) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *TestByteBroken) GetBroken() []byte { + if x != nil { + return x.xxx_hidden_Broken + } + return nil +} + +func (x *TestByteBroken) SetBroken(v []byte) { + if v == nil { + v = []byte{} + } + x.xxx_hidden_Broken = v +} + +type TestByteBroken_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Broken []byte +} + +func (b0 TestByteBroken_builder) Build() *TestByteBroken { + m0 := &TestByteBroken{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Broken = b.Broken + return m0 +} + type TestByteMatching struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_IpAddr []byte `protobuf:"bytes,1,opt,name=ip_addr,json=ipAddr,proto3"` @@ -526,7 +586,7 @@ type TestByteMatching struct { func (x *TestByteMatching) Reset() { *x = TestByteMatching{} - mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + mi := &file_tests_example_v1_native_test_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -538,7 +598,7 @@ func (x *TestByteMatching) String() string { func (*TestByteMatching) ProtoMessage() {} func (x *TestByteMatching) ProtoReflect() protoreflect.Message { - mi := &file_tests_example_v1_native_test_proto_msgTypes[3] + mi := &file_tests_example_v1_native_test_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -635,7 +695,7 @@ type StringMatching struct { func (x *StringMatching) Reset() { *x = StringMatching{} - mi := &file_tests_example_v1_native_test_proto_msgTypes[4] + mi := &file_tests_example_v1_native_test_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -647,7 +707,7 @@ func (x *StringMatching) String() string { func (*StringMatching) ProtoMessage() {} func (x *StringMatching) ProtoReflect() protoreflect.Message { - mi := &file_tests_example_v1_native_test_proto_msgTypes[4] + mi := &file_tests_example_v1_native_test_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -728,7 +788,10 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "TestUnique\x12:\n" + "\x05enums\x18\x01 \x03(\x0e2\x1a.tests.example.v1.TestEnumB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05enums\x12\x1e\n" + "\x05bytes\x18\x02 \x03(\fB\b\xbaH\x05\x92\x01\x02\x18\x01R\x05bytes\x12\"\n" + - "\astrings\x18\x03 \x03(\tB\b\xbaH\x05\x92\x01\x02\x18\x01R\astrings\"\xe9\x01\n" + + "\astrings\x18\x03 \x03(\tB\b\xbaH\x05\x92\x01\x02\x18\x01R\astrings\"5\n" + + "\x0eTestByteBroken\x12#\n" + + "\x06broken\x18\x01 \x01(\fB\v\xbaH\bz\x06\x10\x05\x18\n" + + "x\x01R\x06broken\"\xe9\x01\n" + "\x10TestByteMatching\x126\n" + "\aip_addr\x18\x01 \x01(\fB\x1d\xbaH\x04z\x02P\x01\xca\xe66\x12\x12\x101234567890123456R\x06ipAddr\x12.\n" + "\tipv4_addr\x18\x02 \x01(\fB\x11\xbaH\x04z\x02X\x01\xca\xe66\x06\x12\x041234R\bipv4Addr\x12:\n" + @@ -745,14 +808,15 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\x14com.tests.example.v1B\x0fNativeTestProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" var file_tests_example_v1_native_test_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_tests_example_v1_native_test_proto_goTypes = []any{ (TestEnum)(0), // 0: tests.example.v1.TestEnum (*BenchGT)(nil), // 1: tests.example.v1.BenchGT (*BenchTestBytes)(nil), // 2: tests.example.v1.BenchTestBytes (*TestUnique)(nil), // 3: tests.example.v1.TestUnique - (*TestByteMatching)(nil), // 4: tests.example.v1.TestByteMatching - (*StringMatching)(nil), // 5: tests.example.v1.StringMatching + (*TestByteBroken)(nil), // 4: tests.example.v1.TestByteBroken + (*TestByteMatching)(nil), // 5: tests.example.v1.TestByteMatching + (*StringMatching)(nil), // 6: tests.example.v1.StringMatching } var file_tests_example_v1_native_test_proto_depIdxs = []int32{ 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum @@ -774,7 +838,7 @@ func file_tests_example_v1_native_test_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_native_test_proto_rawDesc), len(file_tests_example_v1_native_test_proto_rawDesc)), NumEnums: 1, - NumMessages: 5, + NumMessages: 6, NumExtensions: 0, NumServices: 0, }, diff --git a/native_bool.go b/native_bool.go index c2bda5c8..dce37347 100644 --- a/native_bool.go +++ b/native_bool.go @@ -55,9 +55,10 @@ type nativeBoolEval struct { func (n nativeBoolEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { if val.Bool() != n.constVal { - return n.newViolation(boolRuleDesc, boolConstDesc, + return &ValidationError{Violations: []*Violation{n.newViolation(boolRuleDesc, boolConstDesc, "bool.const", fmt.Sprintf("must equal %t", n.constVal), - val, protoreflect.ValueOfBool(n.constVal)) + val, protoreflect.ValueOfBool(n.constVal)), + }} } return nil } diff --git a/native_bytes.go b/native_bytes.go index ea328e09..793ef472 100644 --- a/native_bytes.go +++ b/native_bytes.go @@ -269,96 +269,151 @@ type nativeBytesEval struct { } var ( - errNotUTF8 = errors.New("must be valid UTF-8 to apply regexp") + errNotUTF8 = errors.New("must be valid UTF-8 to apply regexp") + utf8Violation = &Violation{} //nolint:gochecknoglobals // used for a pointer compare for the special UTF-8 RuntimeError case. ) -func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { - bytesVal := val.Bytes() - byteLen := uint64(len(bytesVal)) - // const - if n.hasConst && !bytes.Equal(bytesVal, n.constVal) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.constDesc, - "bytes.const", fmt.Sprintf("must be %x", n.constVal), - val, protoreflect.ValueOfBytes(n.constVal)) - } +type bytesProcessor func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, byteLen uint64) *Violation +//nolint:gochecknoglobals // slice of all the processors that are used, value never modified, effectively immutable +var bytesProcessors = []bytesProcessor{ + // const + func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { + if n.hasConst && !bytes.Equal(bytesVal, n.constVal) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.constDesc, + "bytes.const", fmt.Sprintf("must be %x", n.constVal), + val, protoreflect.ValueOfBytes(n.constVal)) + } + return nil + }, // len - if n.exactLen != nil && byteLen != *n.exactLen { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.lenDesc, - "bytes.len", fmt.Sprintf("must be %d bytes", *n.exactLen), - val, protoreflect.ValueOfUint64(*n.exactLen)) - } - + func(n nativeBytesEval, val protoreflect.Value, _ []byte, byteLen uint64) *Violation { + if n.exactLen != nil && byteLen != *n.exactLen { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.lenDesc, + "bytes.len", fmt.Sprintf("must be %d bytes", *n.exactLen), + val, protoreflect.ValueOfUint64(*n.exactLen)) + } + return nil + }, // min_len - if byteLen < n.minLen { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.minLenDesc, - "bytes.min_len", fmt.Sprintf("must be at least %d bytes", n.minLen), - val, protoreflect.ValueOfUint64(n.minLen)) - } - + func(n nativeBytesEval, val protoreflect.Value, _ []byte, byteLen uint64) *Violation { + if byteLen < n.minLen { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.minLenDesc, + "bytes.min_len", fmt.Sprintf("must be at least %d bytes", n.minLen), + val, protoreflect.ValueOfUint64(n.minLen)) + } + return nil + }, // max_len - if byteLen > n.maxLen { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.maxLenDesc, - "bytes.max_len", fmt.Sprintf("must be at most %d bytes", n.maxLen), - val, protoreflect.ValueOfUint64(n.maxLen)) - } - - // pattern (matches against string conversion of bytes) - if n.pattern != nil { - if !utf8.Valid(bytesVal) { - return &RuntimeError{cause: errNotUTF8} + func(n nativeBytesEval, val protoreflect.Value, _ []byte, byteLen uint64) *Violation { + if byteLen > n.maxLen { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.maxLenDesc, + "bytes.max_len", fmt.Sprintf("must be at most %d bytes", n.maxLen), + val, protoreflect.ValueOfUint64(n.maxLen)) } - if !n.pattern.MatchString(string(bytesVal)) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.patternDesc, - "bytes.pattern", fmt.Sprintf("must match regex pattern `%s`", n.patternStr), - val, protoreflect.ValueOfString(n.patternStr)) + return nil + }, + // pattern (matches against string conversion of bytes) + func(nbe nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { + if nbe.pattern != nil { + if !utf8.Valid(bytesVal) { + // this is ugly, but it's the _only_ case that doesn't follow the pattern. + // we are making a fake violation here that we are going to turn into a RuntimeError + // to satisfy the conformance tests expectation. + return utf8Violation + } + + if !nbe.pattern.MatchString(string(bytesVal)) { + return nbe.newViolation(bytesDescs.ruleDesc, bytesDescs.patternDesc, + "bytes.pattern", fmt.Sprintf("must match regex pattern `%s`", nbe.patternStr), + val, protoreflect.ValueOfString(nbe.patternStr)) + } } - } - + return nil + }, // prefix - if n.hasPrefix && !bytes.HasPrefix(bytesVal, n.prefix) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.prefixDesc, - "bytes.prefix", fmt.Sprintf("does not have prefix %x", n.prefix), - val, protoreflect.ValueOfBytes(n.prefix)) - } - + func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { + if n.hasPrefix && !bytes.HasPrefix(bytesVal, n.prefix) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.prefixDesc, + "bytes.prefix", fmt.Sprintf("does not have prefix %x", n.prefix), + val, protoreflect.ValueOfBytes(n.prefix)) + } + return nil + }, // suffix - if n.hasSuffix && !bytes.HasSuffix(bytesVal, n.suffix) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.suffixDesc, - "bytes.suffix", fmt.Sprintf("does not have suffix %x", n.suffix), - val, protoreflect.ValueOfBytes(n.suffix)) - } - + func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { + if n.hasSuffix && !bytes.HasSuffix(bytesVal, n.suffix) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.suffixDesc, + "bytes.suffix", fmt.Sprintf("does not have suffix %x", n.suffix), + val, protoreflect.ValueOfBytes(n.suffix)) + } + return nil + }, // contains - if n.hasContains && !bytes.Contains(bytesVal, n.contains) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.containsDesc, - "bytes.contains", fmt.Sprintf("does not contain %x", n.contains), - val, protoreflect.ValueOfBytes(n.contains)) - } - + func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { + if n.hasContains && !bytes.Contains(bytesVal, n.contains) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.containsDesc, + "bytes.contains", fmt.Sprintf("does not contain %x", n.contains), + val, protoreflect.ValueOfBytes(n.contains)) + } + return nil + }, // in - if len(n.inVals) > 0 && !slices.ContainsFunc(n.inVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.inDesc, - "bytes.in", "must be in list "+formatBytesList(n.inVals), - val, protoreflect.ValueOfBytes(bytesVal)) - } - + func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { + if len(n.inVals) > 0 && !slices.ContainsFunc(n.inVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.inDesc, + "bytes.in", "must be in list "+formatBytesList(n.inVals), + val, protoreflect.ValueOfBytes(bytesVal)) + } + return nil + }, // not_in - if len(n.notInVals) > 0 && slices.ContainsFunc(n.notInVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.notInDesc, - "bytes.not_in", "must not be in list "+formatBytesList(n.notInVals), - val, protoreflect.ValueOfBytes(bytesVal)) - } - + func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { + if len(n.notInVals) > 0 && slices.ContainsFunc(n.notInVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { + return n.newViolation(bytesDescs.ruleDesc, bytesDescs.notInDesc, + "bytes.not_in", "must not be in list "+formatBytesList(n.notInVals), + val, protoreflect.ValueOfBytes(bytesVal)) + } + return nil + }, // well-known format constraints (ip, ipv4, ipv6, uuid) - if n.wellKnown != nil { - return n.evaluateWellKnown(bytesVal, val) + func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { + if n.wellKnown != nil { + return n.evaluateWellKnown(bytesVal, val) + } + return nil + }, +} + +func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + bytesVal := val.Bytes() + byteLen := uint64(len(bytesVal)) + var violations []*Violation + + for _, bytesProcessor := range bytesProcessors { + violation := bytesProcessor(n, val, bytesVal, byteLen) + if violation != nil { + // special case for regex pattern when the bytes aren't a UTF-8 string + if violation == utf8Violation { + return &RuntimeError{cause: errNotUTF8} + } + + violations = append(violations, violation) + if cfg.failFast { + break + } + } } + if len(violations) > 0 { + return &ValidationError{ + Violations: violations, + } + } return nil } -func (n nativeBytesEval) evaluateWellKnown(bytesVal []byte, val protoreflect.Value) error { +func (n nativeBytesEval) evaluateWellKnown(bytesVal []byte, val protoreflect.Value) *Violation { size := len(bytesVal) wellKnown := n.wellKnown diff --git a/native_bytes_test.go b/native_bytes_test.go index 7257e2ba..438c3b05 100644 --- a/native_bytes_test.go +++ b/native_bytes_test.go @@ -15,6 +15,8 @@ package protovalidate import ( + "errors" + "fmt" "testing" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" @@ -236,6 +238,34 @@ func TestNativeBytesUUID(t *testing.T) { assert.Equal(t, "bytes.uuid", valErr.Violations[0].Proto.GetRuleId()) } +// TestNativeBytesBroken runs a test twice, once with CEL, once with native, making sure that when +// we have two rules broken, we get two violations. +// +//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules +func TestNativeBytesBroken(t *testing.T) { + // should fail, want same message from both native and CEL + msg := examplev1.TestByteBroken_builder{ + Broken: []byte("greetings and salutations"), + }.Build() + for _, d := range []bool{false, true} { + t.Run(fmt.Sprintf("useNativeRules=%t", d), func(t *testing.T) { + useNativeRules = d + validator, err := New(WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())) + require.NoError(t, err) + err = validator.Validate(msg) + require.Error(t, err) + var valErr *ValidationError + if errors.As(err, &valErr) { + if len(valErr.Violations) != 2 { + t.Errorf("expected 2 violations, got %d: %v", len(valErr.Violations), valErr) + } + } else { + t.Error("expected a validation error") + } + }) + } +} + func TestTryBuildNativeBytesRules_ReturnsNil(t *testing.T) { t.Parallel() diff --git a/native_enum.go b/native_enum.go index 1eb1996b..3b4af937 100644 --- a/native_enum.go +++ b/native_enum.go @@ -80,29 +80,59 @@ type nativeEnumEval struct { notInVals []int32 } -func (n nativeEnumEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { - enumVal := int32(val.Enum()) - // const - if n.constVal != nil && enumVal != *n.constVal { - return n.newViolation(enumRuleDescriptor, enumConstDesc, - "enum.const", fmt.Sprintf("must equal %d", *n.constVal), - val, protoreflect.ValueOfInt32(*n.constVal)) - } +type enumProcessor func(n nativeEnumEval, val protoreflect.Value, enumVal int32) *Violation +//nolint:gochecknoglobals // slice of all the processors that are used, value never modified, effectively immutable +var enumProcessors = []enumProcessor{ + // const + func(n nativeEnumEval, val protoreflect.Value, enumVal int32) *Violation { + if n.constVal != nil && enumVal != *n.constVal { + return n.newViolation(enumRuleDescriptor, enumConstDesc, + "enum.const", fmt.Sprintf("must equal %d", *n.constVal), + val, protoreflect.ValueOfInt32(*n.constVal)) + } + return nil + }, // in - if len(n.inVals) > 0 && !slices.Contains(n.inVals, enumVal) { - return n.newViolation(enumRuleDescriptor, enumInDesc, - "enum.in", "must be in list "+formatList(n.inVals), - val, protoreflect.ValueOfInt32(enumVal)) - } - + func(n nativeEnumEval, val protoreflect.Value, enumVal int32) *Violation { + if len(n.inVals) > 0 && !slices.Contains(n.inVals, enumVal) { + return n.newViolation(enumRuleDescriptor, enumInDesc, + "enum.in", "must be in list "+formatList(n.inVals), + val, protoreflect.ValueOfInt32(enumVal)) + } + return nil + }, // not_in - if len(n.notInVals) > 0 && slices.Contains(n.notInVals, enumVal) { - return n.newViolation(enumRuleDescriptor, enumNotInDesc, - "enum.not_in", "must not be in list "+formatList(n.notInVals), - val, protoreflect.ValueOfInt32(enumVal)) + func(n nativeEnumEval, val protoreflect.Value, enumVal int32) *Violation { + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, enumVal) { + return n.newViolation(enumRuleDescriptor, enumNotInDesc, + "enum.not_in", "must not be in list "+formatList(n.notInVals), + val, protoreflect.ValueOfInt32(enumVal)) + } + return nil + }, +} + +func (n nativeEnumEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + enumVal := int32(val.Enum()) + + var violations []*Violation + + for _, enumProcessor := range enumProcessors { + violation := enumProcessor(n, val, enumVal) + if violation != nil { + violations = append(violations, violation) + if cfg.failFast { + break + } + } } + if len(violations) > 0 { + return &ValidationError{ + Violations: violations, + } + } return nil } diff --git a/native_map.go b/native_map.go index a24ac854..e8075452 100644 --- a/native_map.go +++ b/native_map.go @@ -80,18 +80,20 @@ func (n nativeMapEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, // min_pairs if size < n.minPairs { - return n.newViolation(mapFieldRulesDesc, mapMinPairsDesc, + return &ValidationError{Violations: []*Violation{n.newViolation(mapFieldRulesDesc, mapMinPairsDesc, "map.min_pairs", fmt.Sprintf("map must be at least %d entries", n.minPairs), - val, protoreflect.ValueOfUint64(n.minPairs)) + val, protoreflect.ValueOfUint64(n.minPairs)), + }} } // max_pairs if size > n.maxPairs { - return n.newViolation(mapFieldRulesDesc, mapMaxPairsDesc, + return &ValidationError{Violations: []*Violation{n.newViolation(mapFieldRulesDesc, mapMaxPairsDesc, "map.max_pairs", fmt.Sprintf("map must be at most %d entries", n.maxPairs), - val, protoreflect.ValueOfUint64(n.maxPairs)) + val, protoreflect.ValueOfUint64(n.maxPairs)), + }} } return nil diff --git a/native_numeric.go b/native_numeric.go index db44bc85..57d31aa3 100644 --- a/native_numeric.go +++ b/native_numeric.go @@ -116,6 +116,93 @@ func tryBuildNativeDoubleRules(base base, rules *validate.DoubleRules) evaluator return tryBuildNativeNumericRules(base, rules, doubleConfig) } +// tryBuildNativeNumericRules attempts to build a native Go evaluator for +// numeric rules. Returns nil if the rules can't be handled natively, +// including cases with unknown fields (custom predefined extensions). +func tryBuildNativeNumericRules[T numericValue, R numericRules[T]]( + base base, + rules R, + config numericTypeConfig[T], +) evaluator { + // Bail out if the rules message has unknown fields, which indicate + // custom predefined extensions that we can't handle natively. + if len(rules.ProtoReflect().GetUnknown()) > 0 { + return nil + } + + hasRule := false + + var lowerValue T + lower := lowerBoundNone + switch { + case rules.HasGt(): + lower = lowerBoundGt + lowerValue = rules.GetGt() + hasRule = true + case rules.HasGte(): + lower = lowerBoundGte + lowerValue = rules.GetGte() + hasRule = true + } + + var upperValue T + upper := upperBoundNone + switch { + case rules.HasLt(): + upper = upperBoundLt + upperValue = rules.GetLt() + hasRule = true + case rules.HasLte(): + upper = upperBoundLte + upperValue = rules.GetLte() + hasRule = true + } + + var constVal *T + if rules.HasConst() { + constVal = ptr(rules.GetConst()) + hasRule = true + } + + var inVals []T + if inVals = rules.GetIn(); len(inVals) > 0 { + hasRule = true + } + + var notInVals []T + if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + hasRule = true + } + + type finiteInterface interface { + HasFinite() bool + GetFinite() bool + } + + finite := false + if fi, ok := (any)(rules).(finiteInterface); ok && fi.HasFinite() { + finite = fi.GetFinite() + hasRule = true + } + + if !hasRule { + return nil + } + + return nativeNumericCompare[T]{ + base: base, + config: config, + lo: lowerValue, + lower: lower, + hi: upperValue, + upper: upper, + constVal: constVal, + inVals: inVals, + notInVals: notInVals, + finite: finite, + } +} + // numericRules is satisfied by all generated numeric rules types // (Int32Rules, Int64Rules, UInt32Rules, etc.). // @@ -401,71 +488,109 @@ func (n nativeNumericCompare[T]) conjunction() string { return "or" } -func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { +func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { valT := n.config.extractVal(val) - // const support - if n.constVal != nil && valT != *n.constVal { - return n.newViolation(n.config.descs.ruleDesc, n.config.descs.constDesc, - n.config.typeName+".const", - fmt.Sprintf("must equal %v", *n.constVal), - val, n.config.makeRuleVal(*n.constVal)) - } - - // in support - if len(n.inVals) > 0 && !slices.Contains(n.inVals, valT) { - return n.newViolation(n.config.descs.ruleDesc, n.config.descs.inDesc, - n.config.typeName+".in", - "must be in list "+formatList(n.inVals), - val, n.config.makeRuleVal(valT)) - } - - // not in support - if len(n.notInVals) > 0 && slices.Contains(n.notInVals, valT) { - return n.newViolation(n.config.descs.ruleDesc, n.config.descs.notInDesc, - n.config.typeName+".not_in", - "must not be in list "+formatList(n.notInVals), - val, n.config.makeRuleVal(valT)) - } + var violations []*Violation + + // for numerics, since it is using generics, we need to instantiate inside the method. We can't have a slice of + // uninstantiated generic functions in Go. Because it's inside the method, we'll just capture the parameters instead + // of passing them in. + type numericProcessor[T numericValue] func() *Violation + + var numericProcessors = []numericProcessor[T]{ + // const support + func() *Violation { + if n.constVal != nil && valT != *n.constVal { + return n.newViolation(n.config.descs.ruleDesc, n.config.descs.constDesc, + n.config.typeName+".const", + fmt.Sprintf("must equal %v", *n.constVal), + val, n.config.makeRuleVal(*n.constVal)) + } + return nil + }, + // in support + func() *Violation { + if len(n.inVals) > 0 && !slices.Contains(n.inVals, valT) { + return n.newViolation(n.config.descs.ruleDesc, n.config.descs.inDesc, + n.config.typeName+".in", + "must be in list "+formatList(n.inVals), + val, n.config.makeRuleVal(valT)) + } + return nil + }, + // not in support + func() *Violation { + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, valT) { + return n.newViolation(n.config.descs.ruleDesc, n.config.descs.notInDesc, + n.config.typeName+".not_in", + "must not be in list "+formatList(n.notInVals), + val, n.config.makeRuleVal(valT)) + } + return nil + }, + // finite support + func() *Violation { + if n.finite && (math.IsNaN(float64(valT)) || math.IsInf(float64(valT), 0)) { + return n.newViolation(n.config.descs.ruleDesc, n.config.descs.finiteDesc, + n.config.typeName+".finite", + "must be finite", + val, n.config.makeRuleVal(valT)) + } + return nil + }, + // range support + func() *Violation { + if n.lower == lowerBoundNone && n.upper == upperBoundNone { + return nil + } - // finite support - if n.finite && (math.IsNaN(float64(valT)) || math.IsInf(float64(valT), 0)) { - return n.newViolation(n.config.descs.ruleDesc, n.config.descs.finiteDesc, - n.config.typeName+".finite", - "must be finite", - val, n.config.makeRuleVal(valT)) + // For float/double, NaN fails all range checks (matches CEL behavior). + isNaN := n.config.nanFailsRange && math.IsNaN(float64(valT)) + + switch { + case n.lower == lowerBoundNone: + if isNaN || n.aboveHi(valT) { + return n.newViolation(n.config.descs.ruleDesc, n.hiDesc(), + n.gtltRule(), "must be "+n.hiMessage(), + val, n.config.makeRuleVal(n.hi)) + } + case n.upper == upperBoundNone: + if isNaN || n.belowLo(valT) { + return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), + n.gtltRule(), "must be "+n.loMessage(), + val, n.config.makeRuleVal(n.lo)) + } + default: + var failure bool + if n.isNormalRange() { + failure = isNaN || n.aboveHi(valT) || n.belowLo(valT) + } else { + failure = isNaN || (n.aboveHi(valT) && n.belowLo(valT)) + } + if failure { + return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), + n.gtltRule(), + fmt.Sprintf("must be %s %s %s", n.loMessage(), n.conjunction(), n.hiMessage()), + val, n.config.makeRuleVal(n.lo)) + } + } + return nil + }, } - if n.lower == lowerBoundNone && n.upper == upperBoundNone { - return nil + for _, numericProcessor := range numericProcessors { + violation := numericProcessor() + if violation != nil { + violations = append(violations, violation) + if cfg.failFast { + break + } + } } - // For float/double, NaN fails all range checks (matches CEL behavior). - isNaN := n.config.nanFailsRange && math.IsNaN(float64(valT)) - switch { - case n.lower == lowerBoundNone: - if isNaN || n.aboveHi(valT) { - return n.newViolation(n.config.descs.ruleDesc, n.hiDesc(), - n.gtltRule(), "must be "+n.hiMessage(), - val, n.config.makeRuleVal(n.hi)) - } - case n.upper == upperBoundNone: - if isNaN || n.belowLo(valT) { - return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), - n.gtltRule(), "must be "+n.loMessage(), - val, n.config.makeRuleVal(n.lo)) - } - default: - var failure bool - if n.isNormalRange() { - failure = isNaN || n.aboveHi(valT) || n.belowLo(valT) - } else { - failure = isNaN || (n.aboveHi(valT) && n.belowLo(valT)) - } - if failure { - return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), - n.gtltRule(), - fmt.Sprintf("must be %s %s %s", n.loMessage(), n.conjunction(), n.hiMessage()), - val, n.config.makeRuleVal(n.lo)) + if len(violations) > 0 { + return &ValidationError{ + Violations: violations, } } return nil @@ -477,93 +602,6 @@ func (n nativeNumericCompare[T]) Tautology() bool { var _ evaluator = nativeNumericCompare[int32]{} -// tryBuildNativeNumericRules attempts to build a native Go evaluator for -// numeric rules. Returns nil if the rules can't be handled natively, -// including cases with unknown fields (custom predefined extensions). -func tryBuildNativeNumericRules[T numericValue, R numericRules[T]]( - base base, - rules R, - config numericTypeConfig[T], -) evaluator { - // Bail out if the rules message has unknown fields, which indicate - // custom predefined extensions that we can't handle natively. - if len(rules.ProtoReflect().GetUnknown()) > 0 { - return nil - } - - hasRule := false - - var lowerValue T - lower := lowerBoundNone - switch { - case rules.HasGt(): - lower = lowerBoundGt - lowerValue = rules.GetGt() - hasRule = true - case rules.HasGte(): - lower = lowerBoundGte - lowerValue = rules.GetGte() - hasRule = true - } - - var upperValue T - upper := upperBoundNone - switch { - case rules.HasLt(): - upper = upperBoundLt - upperValue = rules.GetLt() - hasRule = true - case rules.HasLte(): - upper = upperBoundLte - upperValue = rules.GetLte() - hasRule = true - } - - var constVal *T - if rules.HasConst() { - constVal = ptr(rules.GetConst()) - hasRule = true - } - - var inVals []T - if inVals = rules.GetIn(); len(inVals) > 0 { - hasRule = true - } - - var notInVals []T - if notInVals = rules.GetNotIn(); len(notInVals) > 0 { - hasRule = true - } - - type finiteInterface interface { - HasFinite() bool - GetFinite() bool - } - - finite := false - if fi, ok := (any)(rules).(finiteInterface); ok && fi.HasFinite() { - finite = fi.GetFinite() - hasRule = true - } - - if !hasRule { - return nil - } - - return nativeNumericCompare[T]{ - base: base, - config: config, - lo: lowerValue, - lower: lower, - hi: upperValue, - upper: upper, - constVal: constVal, - inVals: inVals, - notInVals: notInVals, - finite: finite, - } -} - func ptr[T any](v T) *T { return &v } // formatList formats a slice as "list [val1, val2]" to match CEL message format. diff --git a/native_numeric_test.go b/native_numeric_test.go index 7572e76a..37e724c9 100644 --- a/native_numeric_test.go +++ b/native_numeric_test.go @@ -195,7 +195,7 @@ func TestNativeInt32Compare(t *testing.T) { name: "const_with_gte", rules: validate.Int32Rules_builder{Const: proto.Int32(5), Gte: proto.Int32(4)}.Build(), cases: []numericTestCase[int32]{ - {"pass_fail", []int32{5}, []int32{3, 4, 6}, "int32.const"}, + {"pass_fail", []int32{5}, []int32{7, 4, 6}, "int32.const"}, }, }, { diff --git a/native_repeated.go b/native_repeated.go index 5ad9c546..01411fe8 100644 --- a/native_repeated.go +++ b/native_repeated.go @@ -84,34 +84,62 @@ type nativeRepeatedEval struct { unique bool } -func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { - list := val.List() - size := uint64(list.Len()) //nolint:gosec // len can't be < 0 and is always within uint64 range +type repeatedProcessor func(n nativeRepeatedEval, val protoreflect.Value, list protoreflect.List, size uint64) *Violation +//nolint:gochecknoglobals // slice of all the processors that are used, value never modified, effectively immutable +var repeatedProcessors = []repeatedProcessor{ // min_items - if size < n.minItems { - return n.newViolation(repeatedFieldRulesDesc, repeatedMinItemsDesc, - "repeated.min_items", - fmt.Sprintf("must contain at least %d item(s)", n.minItems), - val, protoreflect.ValueOfUint64(n.minItems)) - } - + func(n nativeRepeatedEval, val protoreflect.Value, _ protoreflect.List, size uint64) *Violation { + if size < n.minItems { + return n.newViolation(repeatedFieldRulesDesc, repeatedMinItemsDesc, + "repeated.min_items", + fmt.Sprintf("must contain at least %d item(s)", n.minItems), + val, protoreflect.ValueOfUint64(n.minItems)) + } + return nil + }, // max_items - if size > n.maxItems { - return n.newViolation(repeatedFieldRulesDesc, repeatedMaxItemsDesc, - "repeated.max_items", - fmt.Sprintf("must contain no more than %d item(s)", n.maxItems), - val, protoreflect.ValueOfUint64(n.maxItems)) - } - + func(n nativeRepeatedEval, val protoreflect.Value, _ protoreflect.List, size uint64) *Violation { + if size > n.maxItems { + return n.newViolation(repeatedFieldRulesDesc, repeatedMaxItemsDesc, + "repeated.max_items", + fmt.Sprintf("must contain no more than %d item(s)", n.maxItems), + val, protoreflect.ValueOfUint64(n.maxItems)) + } + return nil + }, // unique - if n.unique && !isUnique(list) { - return n.newViolation(repeatedFieldRulesDesc, repeatedUniqueDesc, - "repeated.unique", - "repeated value must contain unique items", - val, protoreflect.ValueOfBool(true)) + func(n nativeRepeatedEval, val protoreflect.Value, list protoreflect.List, _ uint64) *Violation { + if n.unique && !isUnique(list) { + return n.newViolation(repeatedFieldRulesDesc, repeatedUniqueDesc, + "repeated.unique", + "repeated value must contain unique items", + val, protoreflect.ValueOfBool(true)) + } + return nil + }, +} + +func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + list := val.List() + size := uint64(list.Len()) //nolint:gosec // len can't be < 0 and is always within uint64 range + var violations []*Violation + + for _, repeatedProcessor := range repeatedProcessors { + violation := repeatedProcessor(n, val, list, size) + if violation != nil { + violations = append(violations, violation) + if cfg.failFast { + break + } + } } + if len(violations) > 0 { + return &ValidationError{ + Violations: violations, + } + } return nil } diff --git a/native_string.go b/native_string.go index 8a68bf53..7edcf607 100644 --- a/native_string.go +++ b/native_string.go @@ -446,112 +446,151 @@ type nativeStringEval struct { strict bool } -func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { - strVal := val.String() - - var runeCount uint64 - if n.exactLen != nil || n.minLen != nil || n.maxLen != nil { - runeCount = uint64(utf8.RuneCountInString(strVal)) //nolint:gosec - err := n.evaluateLength(runeCount, val) - if err != nil { - return err +type stringProcessor func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation + +//nolint:gochecknoglobals // slice of all the processors that are used, value never modified, effectively immutable +var stringProcessors = []stringProcessor{ + // rune length + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + var runeCount uint64 + if n.exactLen != nil || n.minLen != nil || n.maxLen != nil { + runeCount = uint64(utf8.RuneCountInString(strVal)) + return n.evaluateLength(runeCount, val) } - } - - var byteCount uint64 - if n.exactBytes != nil || n.minBytes != nil || n.maxBytes != nil { - byteCount = uint64(len(strVal)) - err := n.evaluateByteLength(byteCount, val) - if err != nil { - return err + return nil + }, + // byte length + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + var byteCount uint64 + if n.exactBytes != nil || n.minBytes != nil || n.maxBytes != nil { + byteCount = uint64(len(strVal)) + return n.evaluateByteLength(byteCount, val) } - } - + return nil + }, // const - if n.constVal != nil && strVal != *n.constVal { - return n.newViolation(strDescs.ruleDesc, strDescs.constDesc, - "string.const", fmt.Sprintf("must equal `%s`", *n.constVal), - val, protoreflect.ValueOfString(*n.constVal)) - } - + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if n.constVal != nil && strVal != *n.constVal { + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.constDesc, + "string.const", fmt.Sprintf("must equal `%s`", *n.constVal), + val, protoreflect.ValueOfString(*n.constVal))} + } + return nil + }, // pattern - if n.pattern != nil && !n.pattern.MatchString(strVal) { - return n.newViolation(strDescs.ruleDesc, strDescs.patternDesc, - "string.pattern", fmt.Sprintf("does not match regex pattern `%s`", n.patternStr), - val, protoreflect.ValueOfString(n.patternStr)) - } - + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if n.pattern != nil && !n.pattern.MatchString(strVal) { + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.patternDesc, + "string.pattern", fmt.Sprintf("does not match regex pattern `%s`", n.patternStr), + val, protoreflect.ValueOfString(n.patternStr))} + } + return nil + }, // prefix - if n.prefix != nil && !strings.HasPrefix(strVal, *n.prefix) { - return n.newViolation(strDescs.ruleDesc, strDescs.prefixDesc, - "string.prefix", fmt.Sprintf("does not have prefix `%s`", *n.prefix), - val, protoreflect.ValueOfString(*n.prefix)) - } - + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if n.prefix != nil && !strings.HasPrefix(strVal, *n.prefix) { + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.prefixDesc, + "string.prefix", fmt.Sprintf("does not have prefix `%s`", *n.prefix), + val, protoreflect.ValueOfString(*n.prefix))} + } + return nil + }, // suffix - if n.suffix != nil && !strings.HasSuffix(strVal, *n.suffix) { - return n.newViolation(strDescs.ruleDesc, strDescs.suffixDesc, - "string.suffix", fmt.Sprintf("does not have suffix `%s`", *n.suffix), - val, protoreflect.ValueOfString(*n.suffix)) - } - + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if n.suffix != nil && !strings.HasSuffix(strVal, *n.suffix) { + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.suffixDesc, + "string.suffix", fmt.Sprintf("does not have suffix `%s`", *n.suffix), + val, protoreflect.ValueOfString(*n.suffix))} + } + return nil + }, // contains - if n.contains != nil && !strings.Contains(strVal, *n.contains) { - return n.newViolation(strDescs.ruleDesc, strDescs.containsDesc, - "string.contains", fmt.Sprintf("does not contain substring `%s`", *n.contains), - val, protoreflect.ValueOfString(*n.contains)) - } - + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if n.contains != nil && !strings.Contains(strVal, *n.contains) { + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.containsDesc, + "string.contains", fmt.Sprintf("does not contain substring `%s`", *n.contains), + val, protoreflect.ValueOfString(*n.contains))} + } + return nil + }, // not_contains - if n.notContains != nil && strings.Contains(strVal, *n.notContains) { - return n.newViolation(strDescs.ruleDesc, strDescs.notContainsDesc, - "string.not_contains", fmt.Sprintf("value contains substring `%s`", *n.notContains), - val, protoreflect.ValueOfString(*n.notContains)) - } - + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if n.notContains != nil && strings.Contains(strVal, *n.notContains) { + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.notContainsDesc, + "string.not_contains", fmt.Sprintf("value contains substring `%s`", *n.notContains), + val, protoreflect.ValueOfString(*n.notContains))} + } + return nil + }, // in - if len(n.inVals) > 0 && !slices.Contains(n.inVals, strVal) { - return n.newViolation(strDescs.ruleDesc, strDescs.inDesc, - "string.in", "must be in list "+formatStringList(n.inVals), - val, protoreflect.ValueOfString(strVal)) - } - + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if len(n.inVals) > 0 && !slices.Contains(n.inVals, strVal) { + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.inDesc, + "string.in", "must be in list "+formatStringList(n.inVals), + val, protoreflect.ValueOfString(strVal))} + } + return nil + }, // not_in - if len(n.notInVals) > 0 && slices.Contains(n.notInVals, strVal) { - return n.newViolation(strDescs.ruleDesc, strDescs.notInDesc, - "string.not_in", "must not be in list "+formatStringList(n.notInVals), - val, protoreflect.ValueOfString(strVal)) - } - - if n.wellKnownRule != nil { - if err := n.checkWellKnown(strVal, val); err != nil { - return err + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, strVal) { + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.notInDesc, + "string.not_in", "must not be in list "+formatStringList(n.notInVals), + val, protoreflect.ValueOfString(strVal))} + } + return nil + }, + // well known rules + func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { + if n.wellKnownRule != nil { + return n.checkWellKnown(strVal, val) + } else if n.knownRegex != 0 { + return n.checkKnownRegex(strVal, val) } - } else if n.knownRegex != 0 { - if err := n.checkKnownRegex(strVal, val); err != nil { - return err + return nil + }, +} + +func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + strVal := val.String() + var violations []*Violation + + for _, stringProcessor := range stringProcessors { + violation := stringProcessor(n, val, strVal) + if len(violation) != 0 { + violations = append(violations, violation...) + if cfg.failFast { + // only return the first violation + violations = violations[:1] + break + } } } + if len(violations) > 0 { + return &ValidationError{ + Violations: violations, + } + } return nil } -func (n nativeStringEval) checkWellKnown(strVal string, val protoreflect.Value) error { +func (n nativeStringEval) checkWellKnown(strVal string, val protoreflect.Value) []*Violation { rule := n.wellKnownRule if rule.emptyRuleID != "" && strVal == "" { - return n.newViolation(strDescs.ruleDesc, rule.desc, + return []*Violation{n.newViolation(strDescs.ruleDesc, rule.desc, rule.emptyRuleID, rule.emptyMsg, - val, protoreflect.ValueOfString(strVal)) + val, protoreflect.ValueOfString(strVal))} } if !rule.validate(strVal) { - return n.newViolation(strDescs.ruleDesc, rule.desc, + return []*Violation{n.newViolation(strDescs.ruleDesc, rule.desc, rule.ruleID, rule.mainMsg, - val, protoreflect.ValueOfString(strVal)) + val, protoreflect.ValueOfString(strVal))} } return nil } -func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) error { +func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) []*Violation { // check if strict is set (it is on by default) // if not, just validate against the loose rule (^[^\u0000\u000A\u000D]+$) // if yes, check whether this is a name or value and use the correct strict rule @@ -563,9 +602,9 @@ func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) switch n.knownRegex { case validate.KnownRegex_KNOWN_REGEX_HTTP_HEADER_NAME: if strVal == "" { - return n.newViolation(strDescs.ruleDesc, strDescs.wellKnownRegexDesc, + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.wellKnownRegexDesc, "string.well_known_regex.header_name_empty", "value is empty, which is not a valid HTTP header name", - val, protoreflect.ValueOfString(strVal)) + val, protoreflect.ValueOfString(strVal))} } matcher = headerNameRegexp rule = "string.well_known_regex.header_name" @@ -581,9 +620,9 @@ func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) matcher = looseRegexp } if !matcher.MatchString(strVal) { - return n.newViolation(strDescs.ruleDesc, strDescs.wellKnownRegexDesc, + return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.wellKnownRegexDesc, rule, msg, - val, protoreflect.ValueOfString(strVal)) + val, protoreflect.ValueOfString(strVal))} } return nil } @@ -592,46 +631,49 @@ func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) // very similar bits of code // //nolint:dupl -func (n nativeStringEval) evaluateByteLength(byteCount uint64, val protoreflect.Value) error { +func (n nativeStringEval) evaluateByteLength(byteCount uint64, val protoreflect.Value) []*Violation { + var out []*Violation if n.exactBytes != nil && byteCount != *n.exactBytes { - return n.newViolation(strDescs.ruleDesc, strDescs.lenBytesDesc, + out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.lenBytesDesc, "string.len_bytes", fmt.Sprintf("must be %d bytes", *n.exactBytes), - val, protoreflect.ValueOfUint64(*n.exactBytes)) + val, protoreflect.ValueOfUint64(*n.exactBytes))) } if n.minBytes != nil && byteCount < *n.minBytes { - return n.newViolation(strDescs.ruleDesc, strDescs.minBytesDesc, + out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.minBytesDesc, "string.min_bytes", fmt.Sprintf("must be at least %d bytes", *n.minBytes), - val, protoreflect.ValueOfUint64(*n.minBytes)) + val, protoreflect.ValueOfUint64(*n.minBytes))) } if n.maxBytes != nil && byteCount > *n.maxBytes { - return n.newViolation(strDescs.ruleDesc, strDescs.maxBytesDesc, + out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.maxBytesDesc, "string.max_bytes", fmt.Sprintf("must be at most %d bytes", *n.maxBytes), - val, protoreflect.ValueOfUint64(*n.maxBytes)) + val, protoreflect.ValueOfUint64(*n.maxBytes))) } - return nil + return out } // it would be worse to unify this and evaluateByteLength than it is to leave them as // very similar bits of code // //nolint:dupl -func (n nativeStringEval) evaluateLength(runeCount uint64, val protoreflect.Value) error { +func (n nativeStringEval) evaluateLength(runeCount uint64, val protoreflect.Value) []*Violation { + var out []*Violation + if n.exactLen != nil && runeCount != *n.exactLen { - return n.newViolation(strDescs.ruleDesc, strDescs.lenDesc, + out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.lenDesc, "string.len", fmt.Sprintf("must be %d characters", *n.exactLen), - val, protoreflect.ValueOfUint64(*n.exactLen)) + val, protoreflect.ValueOfUint64(*n.exactLen))) } if n.minLen != nil && runeCount < *n.minLen { - return n.newViolation(strDescs.ruleDesc, strDescs.minLenDesc, + out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.minLenDesc, "string.min_len", fmt.Sprintf("must be at least %d characters", *n.minLen), - val, protoreflect.ValueOfUint64(*n.minLen)) + val, protoreflect.ValueOfUint64(*n.minLen))) } if n.maxLen != nil && runeCount > *n.maxLen { - return n.newViolation(strDescs.ruleDesc, strDescs.maxLenDesc, + out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.maxLenDesc, "string.max_len", fmt.Sprintf("must be at most %d characters", *n.maxLen), - val, protoreflect.ValueOfUint64(*n.maxLen)) + val, protoreflect.ValueOfUint64(*n.maxLen))) } - return nil + return out } func (n nativeStringEval) Tautology() bool { diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto index 6d56eddb..a70d30f4 100644 --- a/proto/tests/example/v1/native_test.proto +++ b/proto/tests/example/v1/native_test.proto @@ -141,6 +141,14 @@ message TestUnique { repeated string strings = 3 [(buf.validate.field).repeated.unique = true]; } +message TestByteBroken { + bytes broken = 1 [ + (buf.validate.field).bytes.min_len = 5, + (buf.validate.field).bytes.max_len = 10, + (buf.validate.field).bytes.uuid = true + ]; +} + message TestByteMatching { bytes ip_addr = 1 [ (buf.validate.field).bytes.ip = true, From 2b512f5ff8b436d73298babfbbeba7a15f3177c4 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 23 Apr 2026 12:27:54 -0400 Subject: [PATCH 07/14] fix formatting on proto --- proto/tests/example/v1/native_test.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto index a70d30f4..bd0c7977 100644 --- a/proto/tests/example/v1/native_test.proto +++ b/proto/tests/example/v1/native_test.proto @@ -142,7 +142,7 @@ message TestUnique { } message TestByteBroken { - bytes broken = 1 [ + bytes broken = 1 [ (buf.validate.field).bytes.min_len = 5, (buf.validate.field).bytes.max_len = 10, (buf.validate.field).bytes.uuid = true From 120d87ec451590b9e9d016d865cd17c75809ff37 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 23 Apr 2026 15:40:46 -0400 Subject: [PATCH 08/14] create a new ValidatorOption WithDisableNativeRules to control whether or not native rules are used. Update tests to use it and allow them to run in parallel. --- Makefile | 13 ++-- README.md | 8 +-- builder.go | 11 ++-- builder_test.go | 2 +- disable_native_rules.go | 21 ------- dynamic_end_to_end_test.go | 62 +++++++++---------- .../cmd/protovalidate-conformance-go/main.go | 12 +++- native_bytes_test.go | 23 ++++--- native_selection_test.go | 29 +++------ option.go | 14 ++++- validator.go | 2 + validator_bench_test.go | 20 +++++- 12 files changed, 101 insertions(+), 116 deletions(-) delete mode 100644 disable_native_rules.go diff --git a/Makefile b/Makefile index e9cfd9c3..9457d969 100644 --- a/Makefile +++ b/Makefile @@ -34,12 +34,10 @@ clean: ## Delete intermediate build artifacts .PHONY: test test: ## Run all unit tests with and without native rules $(GO) test -race -cover ./... - $(GO) test -race -cover -tags="cel_rules" ./... .PHONY: test-opaque test-opaque: ## Test proto opaque API support $(GO) test --tags=protoopaque ./... - $(GO) test -tags="protoopaque cel_rules" ./... .PHONY: lint lint: lint-proto lint-go ## Lint code and protos @@ -59,13 +57,14 @@ lint-fix: $(BIN)/golangci-lint fmt .PHONY: conformance -conformance: $(BIN)/protovalidate-conformance protovalidate-conformance-go protovalidate-conformance-go-cel ## Run conformance tests +conformance: $(BIN)/protovalidate-conformance protovalidate-conformance-go ## Run conformance tests $(BIN)/protovalidate-conformance $(ARGS) $(BIN)/protovalidate-conformance-go --expected_failures=conformance/expected_failures.yaml - $(BIN)/protovalidate-conformance $(ARGS) $(BIN)/protovalidate-conformance-go-cel --expected_failures=conformance/expected_failures.yaml + DISABLE_NATIVE_RULES=true $(BIN)/protovalidate-conformance $(ARGS) $(BIN)/protovalidate-conformance-go --expected_failures=conformance/expected_failures.yaml .PHONY: conformance-hyperpb conformance-hyperpb: ## Run conformance tests against hyperpb HYPERPB=true $(MAKE) conformance + HYPERPB=true DISABLE_NATIVE_RULES=true $(MAKE) conformance .PHONY: generate generate: generate-proto generate-license ## Regenerate code and license headers @@ -103,7 +102,7 @@ bench: $(BENCH_TMP) .PHONY: bench-cel bench-cel: $(BENCH_TMP) - go test -tags "cel_rules" -run ^$$ -bench="$(BENCH)" -benchmem \ + DISABLE_NATIVE_RULES=true go test -run ^$$ -bench="$(BENCH)" -benchmem \ -memprofile "$(BENCH_TMP)/$(BENCH_NAME).mem.profile" \ -cpuprofile "$(BENCH_TMP)/$(BENCH_NAME).cpu.profile" \ -count $(BENCH_COUNT) \ @@ -138,7 +137,3 @@ $(BIN)/protovalidate-conformance: $(BIN) Makefile .PHONY: protovalidate-conformance-go protovalidate-conformance-go: $(BIN) GOBIN=$(abspath $(BIN)) $(GO) install ./internal/cmd/protovalidate-conformance-go - -.PHONY: protovalidate-conformance-go-cel -protovalidate-conformance-go-cel: $(BIN) - GOBIN=$(abspath $(BIN)) $(GO) build -o $(BIN)/protovalidate-conformance-go-cel -tags "cel_rules" ./internal/cmd/protovalidate-conformance-go diff --git a/README.md b/README.md index b5d17f1e..620e0f4b 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,10 @@ API documentation for Go is available on [pkg.go.dev][pkg-go]. ### Native standard validation rules This release provides native support for standard validation rule processing. They are enabled by default -and can be disabled by compiling with the build tag `cel_rules`: - -``` -go build -tags="cel_rules" ... -``` +and can be disabled by setting the ValidatorOption `WithDisableNativeRules`. We continue to validate that the native rules and the CEL rules produce identical results. -The `compliance`, `test`, and `test-opaque` Makefile targets have been updated to run twice, +The `compliance` Makefile target has been updated to run twice, once with the native rules enabled, and once with the CEL rules enabled. Performance improvements on the included benchmarks: diff --git a/builder.go b/builder.go index eb07e81a..45034482 100644 --- a/builder.go +++ b/builder.go @@ -49,6 +49,7 @@ type builder struct { extensionTypeResolver protoregistry.ExtensionTypeResolver allowUnknownFields bool Load func(desc protoreflect.MessageDescriptor) messageEvaluator + disableNativeRules bool } // newBuilder initializes a new Builder. @@ -57,6 +58,7 @@ func newBuilder( disableLazy bool, extensionTypeResolver protoregistry.ExtensionTypeResolver, allowUnknownFields bool, + disableNativeRules bool, seedDesc ...protoreflect.MessageDescriptor, ) *builder { bldr := &builder{ @@ -64,6 +66,7 @@ func newBuilder( rules: newCache(), extensionTypeResolver: extensionTypeResolver, allowUnknownFields: allowUnknownFields, + disableNativeRules: disableNativeRules, } if disableLazy { @@ -450,12 +453,6 @@ func (bldr *builder) processWrapperRules( return nil } -// this flag will only be flipped by tests or by the init() function in disable_native_rules.go -// when the build tag cel_rules is set. -// -//nolint:gochecknoglobals // see the above comment -var useNativeRules = true - func (bldr *builder) processStandardRules( fdesc protoreflect.FieldDescriptor, rules *validate.FieldRules, @@ -473,7 +470,7 @@ func (bldr *builder) processStandardRules( // put behind a feature flag to allow for testing. // it's easier to follow like this, don't break it up //nolint:nestif - if useNativeRules { + if !bldr.disableNativeRules { // Try native Go evaluators for repeated list-level rules (min_items, max_items, unique). if fdesc.IsList() && valEval.NestedRule == nil { if native := tryNativeRepeatedRules(newBase(valEval), rules.GetRepeated()); native != nil { diff --git a/builder_test.go b/builder_test.go index a294e82e..1fcd6b8b 100644 --- a/builder_test.go +++ b/builder_test.go @@ -34,7 +34,7 @@ func TestBuildCache(t *testing.T) { env, err := cel.NewEnv(cel.Lib(pvcel.NewLibrary())) require.NoError(t, err, "failed to construct CEL environment") bldr := newBuilder( - env, false, protoregistry.GlobalTypes, false, + env, false, protoregistry.GlobalTypes, false, false, ) wg := sync.WaitGroup{} for i := range 100 { diff --git a/disable_native_rules.go b/disable_native_rules.go deleted file mode 100644 index 528be73e..00000000 --- a/disable_native_rules.go +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright 2023-2026 Buf Technologies, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//go:build cel_rules - -package protovalidate - -func init() { - useNativeRules = false -} diff --git a/dynamic_end_to_end_test.go b/dynamic_end_to_end_test.go index 43375b1a..75e47e85 100644 --- a/dynamic_end_to_end_test.go +++ b/dynamic_end_to_end_test.go @@ -29,8 +29,8 @@ import ( "google.golang.org/protobuf/types/dynamicpb" ) -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestDynamicRulesEndToEnd(t *testing.T) { + t.Parallel() data := []struct { name string typ *descriptorpb.FieldDescriptorProto_Type @@ -131,7 +131,7 @@ func TestDynamicRulesEndToEnd(t *testing.T) { } for _, d := range data { t.Run(d.name, func(t *testing.T) { - // we are setting an environment variable to enable native rules, so we can't use parallel tests + t.Parallel() msgType := newDynamicMessageType(t, "test.native", "TestMessage", &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), Number: proto.Int32(1), @@ -141,17 +141,15 @@ func TestDynamicRulesEndToEnd(t *testing.T) { }) d.info.msgType = msgType // first with CEL rules - useNativeRules = false - dynamicMessageTester(t, d.info, "value") + dynamicMessageTester(t, d.info, "value", false) // now with native rules to validate they produce identical results - useNativeRules = true - dynamicMessageTester(t, d.info, "value") + dynamicMessageTester(t, d.info, "value", true) }) } } -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestDynamicRepeatedRulesEndToEnd(t *testing.T) { + t.Parallel() data := []struct { name string typ *descriptorpb.FieldDescriptorProto_Type @@ -227,9 +225,8 @@ func TestDynamicRepeatedRulesEndToEnd(t *testing.T) { }, } for _, d := range data { - //nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules t.Run(d.name, func(t *testing.T) { - // we are setting an environment variable to enable native rules, so we can't use parallel tests + t.Parallel() msgType := newDynamicMessageType(t, "test.native", "TestMessage", &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), Number: proto.Int32(1), @@ -252,17 +249,15 @@ func TestDynamicRepeatedRulesEndToEnd(t *testing.T) { d.info.badValue = badList // first with CEL rules - useNativeRules = false - dynamicMessageTester(t, d.info, "value") + dynamicMessageTester(t, d.info, "value", false) // now with native rules to validate they produce identical results - useNativeRules = true - dynamicMessageTester(t, d.info, "value") + dynamicMessageTester(t, d.info, "value", true) }) } } -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeEnum_EndToEnd(t *testing.T) { + t.Parallel() // Build a proto with an enum field and const rule. enumDesc := &descriptorpb.EnumDescriptorProto{ Name: proto.String("TestEnum"), @@ -304,6 +299,7 @@ func TestNativeEnum_EndToEnd(t *testing.T) { } for _, d := range data { t.Run(d.name, func(t *testing.T) { + t.Parallel() msgType := newDynamicMessageTypeWithEnum(t, "test.native", "EnumMsg", enumDesc, &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), Number: proto.Int32(1), @@ -314,17 +310,15 @@ func TestNativeEnum_EndToEnd(t *testing.T) { }) d.info.msgType = msgType // first with CEL rules - useNativeRules = false - dynamicMessageTester(t, d.info, "value") + dynamicMessageTester(t, d.info, "value", false) // now with native rules to validate they produce identical results - useNativeRules = true - dynamicMessageTester(t, d.info, "value") + dynamicMessageTester(t, d.info, "value", true) }) } } -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeMap_EndToEnd(t *testing.T) { + t.Parallel() data := []struct { name string rule *validate.FieldRules @@ -358,8 +352,8 @@ func TestNativeMap_EndToEnd(t *testing.T) { }, } for _, d := range data { - //nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules t.Run(d.name, func(t *testing.T) { + t.Parallel() msgType := newDynamicMapMessageType(t, "test.native", "MapMsg", descriptorpb.FieldDescriptorProto_TYPE_STRING, descriptorpb.FieldDescriptorProto_TYPE_STRING, @@ -386,11 +380,9 @@ func TestNativeMap_EndToEnd(t *testing.T) { d.info.badValue = maker(d.badMap) // first with CEL rules - useNativeRules = false - dynamicMessageTester(t, d.info, "entries") + dynamicMessageTester(t, d.info, "entries", true) // now with native rules to validate they produce identical results - useNativeRules = true - dynamicMessageTester(t, d.info, "entries") + dynamicMessageTester(t, d.info, "entries", false) }) } } @@ -403,9 +395,13 @@ type dynamicMessageTesterInfo struct { failedRuleMessage string } -func dynamicMessageTester(t *testing.T, info dynamicMessageTesterInfo, fieldName protoreflect.Name) { +func dynamicMessageTester(t *testing.T, info dynamicMessageTesterInfo, fieldName protoreflect.Name, disableNativeRules bool) { t.Helper() - validator, err := New(WithDisableLazy(), WithMessageDescriptors(info.msgType.Descriptor())) + options := []ValidatorOption{WithDisableLazy(), WithMessageDescriptors(info.msgType.Descriptor())} + if disableNativeRules { + options = append(options, WithDisableNativeRules()) + } + validator, err := New(options...) require.NoError(t, err) passing := dynamicpb.NewMessage(info.msgType.Descriptor()) @@ -426,9 +422,8 @@ func dynamicMessageTester(t *testing.T, info dynamicMessageTesterInfo, fieldName // TestEnumCombinedRules validates that enum fields work correctly when // multiple rules are active: native const/in/not_in (via processStandardRules) // combined with native defined_only (via processEnumRules). -// -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestEnumCombinedRules(t *testing.T) { + t.Parallel() enumDesc := &descriptorpb.EnumDescriptorProto{ Name: proto.String("Status"), Value: []*descriptorpb.EnumValueDescriptorProto{ @@ -520,8 +515,8 @@ func TestEnumCombinedRules(t *testing.T) { } for _, tt := range tests { - //nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules t.Run(tt.name, func(t *testing.T) { + t.Parallel() msgType := newDynamicMessageTypeWithEnum(t, "test.combined", "EnumCombined", enumDesc, &descriptorpb.FieldDescriptorProto{ Name: proto.String("status"), Number: proto.Int32(1), @@ -533,10 +528,13 @@ func TestEnumCombinedRules(t *testing.T) { // Run with both CEL and native to verify identical results. for _, mode := range []bool{false, true} { - t.Run(fmt.Sprintf("native=%v", mode), func(t *testing.T) { - useNativeRules = mode + t.Run(fmt.Sprintf("native=%v", !mode), func(t *testing.T) { + options := []ValidatorOption{WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())} + if mode { + options = append(options, WithDisableNativeRules()) + } - validator, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) + validator, err := New(options...) require.NoError(t, err) msg := dynamicpb.NewMessage(msgType.Descriptor()) diff --git a/internal/cmd/protovalidate-conformance-go/main.go b/internal/cmd/protovalidate-conformance-go/main.go index 663ef2f3..63eee15a 100644 --- a/internal/cmd/protovalidate-conformance-go/main.go +++ b/internal/cmd/protovalidate-conformance-go/main.go @@ -33,7 +33,8 @@ import ( ) type Config struct { - HyperPB bool + HyperPB bool + DisableNativeRules bool } func main() { @@ -41,7 +42,8 @@ func main() { log.SetPrefix("[protovalidate-go] ") config := Config{ - HyperPB: os.Getenv("HYPERPB") != "", + HyperPB: os.Getenv("HYPERPB") != "", + DisableNativeRules: strings.EqualFold(os.Getenv("DISABLE_NATIVE_RULES"), "true"), } req := &harness.TestConformanceRequest{} @@ -81,7 +83,11 @@ func TestConformance(req *harness.TestConformanceRequest, config Config) (*harne if err != nil { return nil, err } - val, err := protovalidate.New(protovalidate.WithExtensionTypeResolver(registry)) + options := []protovalidate.ValidatorOption{protovalidate.WithExtensionTypeResolver(registry)} + if config.DisableNativeRules { + options = append(options, protovalidate.WithDisableNativeRules()) + } + val, err := protovalidate.New(options...) if err != nil { err = fmt.Errorf("failed to initialize validator: %w", err) return nil, err diff --git a/native_bytes_test.go b/native_bytes_test.go index 438c3b05..a5745e9d 100644 --- a/native_bytes_test.go +++ b/native_bytes_test.go @@ -30,15 +30,14 @@ import ( "google.golang.org/protobuf/types/dynamicpb" ) -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeBytes(t *testing.T) { + t.Parallel() msg := examplev1.BenchTestBytes_builder{ B1: []byte{'\x32', '\x33'}, B: []byte{'\x03', '\x04'}, }.Build() // with native rules off - useNativeRules = false - val, err := New(WithMessages(msg), WithDisableLazy()) + val, err := New(WithMessages(msg), WithDisableLazy(), WithDisableNativeRules()) if err != nil { t.Fatalf("native off: expected no error, got %v", err) } @@ -55,7 +54,6 @@ func TestNativeBytes(t *testing.T) { } // with native rules on - useNativeRules = true val, err = New(WithMessages(msg), WithDisableLazy()) if err != nil { t.Fatalf("native on: expected no error, got %v", err) @@ -240,17 +238,20 @@ func TestNativeBytesUUID(t *testing.T) { // TestNativeBytesBroken runs a test twice, once with CEL, once with native, making sure that when // we have two rules broken, we get two violations. -// -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeBytesBroken(t *testing.T) { + t.Parallel() // should fail, want same message from both native and CEL msg := examplev1.TestByteBroken_builder{ Broken: []byte("greetings and salutations"), }.Build() for _, d := range []bool{false, true} { - t.Run(fmt.Sprintf("useNativeRules=%t", d), func(t *testing.T) { - useNativeRules = d - validator, err := New(WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())) + t.Run(fmt.Sprintf("disableNativeRules=%t", d), func(t *testing.T) { + t.Parallel() + options := []ValidatorOption{WithDisableLazy(), WithMessageDescriptors(msg.ProtoReflect().Descriptor())} + if d { + options = append(options, WithDisableNativeRules()) + } + validator, err := New(options...) require.NoError(t, err) err = validator.Validate(msg) require.Error(t, err) @@ -285,10 +286,8 @@ func TestTryBuildNativeBytesRules_ReturnsNil(t *testing.T) { } } -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeBytes_EndToEnd(t *testing.T) { - useNativeRules = true - + t.Parallel() msgType := newDynamicMessageType(t, "test.native", "BytesMsg", &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), Number: proto.Int32(1), diff --git a/native_selection_test.go b/native_selection_test.go index 133819ef..4f20ae3c 100644 --- a/native_selection_test.go +++ b/native_selection_test.go @@ -26,11 +26,9 @@ import ( "google.golang.org/protobuf/types/descriptorpb" ) -// TestNativeEvaluatorSelection verifies that when useNativeRules=true, -// the builder selects native Go evaluators instead of CEL programs. -// -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules +// TestNativeEvaluatorSelection verifies native rules. func TestNativeEvaluatorSelection(t *testing.T) { + t.Parallel() tests := []struct { name string fieldType descriptorpb.FieldDescriptorProto_Type @@ -131,10 +129,8 @@ func TestNativeEvaluatorSelection(t *testing.T) { } for _, tt := range tests { - //nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules t.Run(tt.name, func(t *testing.T) { - // With useNativeRules=true, should use native evaluator. - useNativeRules = true + t.Parallel() msgType := newDynamicMessageType(t, "test.sel", "Msg", &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), Number: proto.Int32(1), @@ -155,11 +151,8 @@ func TestNativeEvaluatorSelection(t *testing.T) { // TestNativeEvaluatorSelection_Enum tests enum separately since it needs // an enum descriptor in the file. -// -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeEvaluatorSelection_Enum(t *testing.T) { - useNativeRules = true - + t.Parallel() enumDesc := &descriptorpb.EnumDescriptorProto{ Name: proto.String("TestEnum"), Value: []*descriptorpb.EnumValueDescriptorProto{ @@ -189,11 +182,8 @@ func TestNativeEvaluatorSelection_Enum(t *testing.T) { // TestNativeEvaluatorSelection_Map tests map separately since it needs // a map field descriptor. -// -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestNativeEvaluatorSelection_Map(t *testing.T) { - useNativeRules = true - + t.Parallel() msgType := newDynamicMapMessageType(t, "test.sel", "MapMsg", descriptorpb.FieldDescriptorProto_TYPE_STRING, descriptorpb.FieldDescriptorProto_TYPE_STRING, @@ -211,13 +201,10 @@ func TestNativeEvaluatorSelection_Map(t *testing.T) { assertNoCELPrograms(t, evals) } -// TestCELFallbackWithoutNativeFlag verifies that without useNativeRules, +// TestCELFallbackWithoutNativeFlag verifies that without native rules, // the builder uses CEL programs. -// -//nolint:paralleltest // this test flips the useNativeRules global variable to test both native and cel rules func TestCELFallbackWithoutNativeFlag(t *testing.T) { - useNativeRules = false - + t.Parallel() msgType := newDynamicMessageType(t, "test.sel", "CELMsg", &descriptorpb.FieldDescriptorProto{ Name: proto.String("value"), Number: proto.Int32(1), @@ -228,7 +215,7 @@ func TestCELFallbackWithoutNativeFlag(t *testing.T) { }.Build()), }) - v, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor())) + v, err := New(WithDisableLazy(), WithMessageDescriptors(msgType.Descriptor()), WithDisableNativeRules()) require.NoError(t, err) evals := findFieldEvaluators(t, v, msgType.Descriptor(), "value") diff --git a/option.go b/option.go index a132dbac..7eafc764 100644 --- a/option.go +++ b/option.go @@ -22,7 +22,7 @@ import ( ) // A ValidatorOption modifies the default configuration of a Validator. See the -// individual options for their defaults and affects on the fallibility of +// individual options for their defaults and effect on the fallibility of // configuring a Validator. type ValidatorOption interface { applyToValidator(cfg *config) @@ -105,6 +105,12 @@ func WithFailFast() Option { return &failFastOption{} } +// WithDisableNativeRules specifies whether validation should always use CEL rules. +// By default, native rules are used when they exist. +func WithDisableNativeRules() ValidatorOption { + return &disableNativeRulesOption{} +} + // WithNowFunc specifies the function used to derive the `now` variable in CEL // expressions. By default, [timestamppb.Now] is used. func WithNowFunc(fn func() *timestamppb.Timestamp) Option { @@ -159,6 +165,12 @@ func (o *failFastOption) applyToValidation(cfg *validationConfig) { cfg.failFast = true } +type disableNativeRulesOption struct{} + +func (o *disableNativeRulesOption) applyToValidator(cfg *config) { + cfg.disableNativeRules = true +} + type nowFuncOption func() *timestamppb.Timestamp func (o nowFuncOption) applyToValidator(cfg *config) { diff --git a/validator.go b/validator.go index 2267d193..b9107a6a 100644 --- a/validator.go +++ b/validator.go @@ -84,6 +84,7 @@ func New(options ...ValidatorOption) (Validator, error) { cfg.disableLazy, cfg.extensionTypeResolver, cfg.allowUnknownFields, + cfg.disableNativeRules, cfg.desc..., ) @@ -142,6 +143,7 @@ type config struct { extensionTypeResolver protoregistry.ExtensionTypeResolver allowUnknownFields bool nowFn func() *timestamppb.Timestamp + disableNativeRules bool } type validationConfig struct { diff --git a/validator_bench_test.go b/validator_bench_test.go index 3013c3fc..0176d78d 100644 --- a/validator_bench_test.go +++ b/validator_bench_test.go @@ -15,6 +15,8 @@ package protovalidate import ( + "os" + "strings" "testing" pb "buf.build/go/protovalidate/internal/gen/tests/example/v1" @@ -118,8 +120,12 @@ func BenchmarkCompile(b *testing.B) { // Measures compile-time allocations for complex schemas msg := &pb.BenchComplexSchema{} b.ReportAllocs() + options := []ValidatorOption{WithMessages(msg), WithDisableLazy()} + if strings.EqualFold(os.Getenv("DISABLE_NATIVE_RULES"), "true") { + options = append(options, WithDisableNativeRules()) + } for b.Loop() { - _, _ = New(WithMessages(msg), WithDisableLazy()) + _, _ = New(options...) } } @@ -127,8 +133,12 @@ func BenchmarkCompileInt32GT(b *testing.B) { // Measures compile-time allocations for complex schemas msg := &pb.BenchGT{} b.ReportAllocs() + options := []ValidatorOption{WithMessages(msg), WithDisableLazy()} + if strings.EqualFold(os.Getenv("DISABLE_NATIVE_RULES"), "true") { + options = append(options, WithDisableNativeRules()) + } for b.Loop() { - _, _ = New(WithMessages(msg), WithDisableLazy()) + _, _ = New(options...) } } @@ -144,7 +154,11 @@ func testSuccess(t *testing.T, msg proto.Message) { func benchSuccess(b *testing.B, msg proto.Message) { faker := protogofakeit.New(gofakeit.New(1)) require.NoError(b, faker.FakeProto(msg)) - val, err := New(WithMessages(msg), WithDisableLazy()) + options := []ValidatorOption{WithMessages(msg), WithDisableLazy()} + if strings.EqualFold(os.Getenv("DISABLE_NATIVE_RULES"), "true") { + options = append(options, WithDisableNativeRules()) + } + val, err := New(options...) require.NoError(b, err) b.ReportAllocs() From 80b27549d2adf4580b56419c95a25b9d5f3e67f7 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 23 Apr 2026 17:27:52 -0400 Subject: [PATCH 09/14] backed out closure implementation (was too slow). --- .../gen/tests/example/v1/native_test.pb.go | 34 ++- .../example/v1/native_test_protoopaque.pb.go | 34 ++- native_bytes.go | 211 ++++++++---------- native_numeric.go | 172 +++++++------- native_repeated.go | 201 ++++++++++------- native_repeated_test.go | 75 +++++++ native_string.go | 203 ++++++++--------- proto/tests/example/v1/native_test.proto | 8 + validator_bench_test.go | 4 + 9 files changed, 550 insertions(+), 392 deletions(-) diff --git a/internal/gen/tests/example/v1/native_test.pb.go b/internal/gen/tests/example/v1/native_test.pb.go index 8090d234..4f816873 100644 --- a/internal/gen/tests/example/v1/native_test.pb.go +++ b/internal/gen/tests/example/v1/native_test.pb.go @@ -689,6 +689,8 @@ type StringMatching struct { state protoimpl.MessageState `protogen:"hybrid.v1"` Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` HostAndPort string `protobuf:"bytes,2,opt,name=host_and_port,json=hostAndPort,proto3" json:"host_and_port,omitempty"` + Email string `protobuf:"bytes,3,opt,name=email,proto3" json:"email,omitempty"` + Uuid string `protobuf:"bytes,4,opt,name=uuid,proto3" json:"uuid,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -732,6 +734,20 @@ func (x *StringMatching) GetHostAndPort() string { return "" } +func (x *StringMatching) GetEmail() string { + if x != nil { + return x.Email + } + return "" +} + +func (x *StringMatching) GetUuid() string { + if x != nil { + return x.Uuid + } + return "" +} + func (x *StringMatching) SetHostname(v string) { x.Hostname = v } @@ -740,11 +756,21 @@ func (x *StringMatching) SetHostAndPort(v string) { x.HostAndPort = v } +func (x *StringMatching) SetEmail(v string) { + x.Email = v +} + +func (x *StringMatching) SetUuid(v string) { + x.Uuid = v +} + type StringMatching_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. Hostname string HostAndPort string + Email string + Uuid string } func (b0 StringMatching_builder) Build() *StringMatching { @@ -753,6 +779,8 @@ func (b0 StringMatching_builder) Build() *StringMatching { _, _ = b, x x.Hostname = b.Hostname x.HostAndPort = b.HostAndPort + x.Email = b.Email + x.Uuid = b.Uuid return m0 } @@ -796,10 +824,12 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\aip_addr\x18\x01 \x01(\fB\x1d\xbaH\x04z\x02P\x01\xca\xe66\x12\x12\x101234567890123456R\x06ipAddr\x12.\n" + "\tipv4_addr\x18\x02 \x01(\fB\x11\xbaH\x04z\x02X\x01\xca\xe66\x06\x12\x041234R\bipv4Addr\x12:\n" + "\tipv6_addr\x18\x03 \x01(\fB\x1d\xbaH\x04z\x02`\x01\xca\xe66\x12\x12\x101234567890123456R\bipv6Addr\x121\n" + - "\x04uuid\x18\x04 \x01(\fB\x1d\xbaH\x04z\x02x\x01\xca\xe66\x12\x12\x101234567890123456R\x04uuid\"\x8c\x01\n" + + "\x04uuid\x18\x04 \x01(\fB\x1d\xbaH\x04z\x02x\x01\xca\xe66\x12\x12\x101234567890123456R\x04uuid\"\xe2\x01\n" + "\x0eStringMatching\x125\n" + "\bhostname\x18\x01 \x01(\tB\x19\xbaH\x04r\x02h\x01\xca\xe66\x0e\x12\f{domainname}R\bhostname\x12C\n" + - "\rhost_and_port\x18\x02 \x01(\tB\x1f\xbaH\x05r\x03\x80\x02\x01\xca\xe66\x13\x12\x11{domainname}:8080R\vhostAndPort*a\n" + + "\rhost_and_port\x18\x02 \x01(\tB\x1f\xbaH\x05r\x03\x80\x02\x01\xca\xe66\x13\x12\x11{domainname}:8080R\vhostAndPort\x12*\n" + + "\x05email\x18\x03 \x01(\tB\x14\xbaH\x04r\x02`\x01\xca\xe66\t\x12\a{email}R\x05email\x12(\n" + + "\x04uuid\x18\x04 \x01(\tB\x14\xbaH\x05r\x03\xb0\x01\x01\xca\xe66\b\x12\x06{uuid}R\x04uuid*a\n" + "\bTestEnum\x12\x19\n" + "\x15TEST_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eTEST_ENUM_VAL1\x10\x01\x12\x12\n" + diff --git a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go index 1aa80822..f3670a55 100644 --- a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go +++ b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go @@ -689,6 +689,8 @@ type StringMatching struct { state protoimpl.MessageState `protogen:"opaque.v1"` xxx_hidden_Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3"` xxx_hidden_HostAndPort string `protobuf:"bytes,2,opt,name=host_and_port,json=hostAndPort,proto3"` + xxx_hidden_Email string `protobuf:"bytes,3,opt,name=email,proto3"` + xxx_hidden_Uuid string `protobuf:"bytes,4,opt,name=uuid,proto3"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -732,6 +734,20 @@ func (x *StringMatching) GetHostAndPort() string { return "" } +func (x *StringMatching) GetEmail() string { + if x != nil { + return x.xxx_hidden_Email + } + return "" +} + +func (x *StringMatching) GetUuid() string { + if x != nil { + return x.xxx_hidden_Uuid + } + return "" +} + func (x *StringMatching) SetHostname(v string) { x.xxx_hidden_Hostname = v } @@ -740,11 +756,21 @@ func (x *StringMatching) SetHostAndPort(v string) { x.xxx_hidden_HostAndPort = v } +func (x *StringMatching) SetEmail(v string) { + x.xxx_hidden_Email = v +} + +func (x *StringMatching) SetUuid(v string) { + x.xxx_hidden_Uuid = v +} + type StringMatching_builder struct { _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. Hostname string HostAndPort string + Email string + Uuid string } func (b0 StringMatching_builder) Build() *StringMatching { @@ -753,6 +779,8 @@ func (b0 StringMatching_builder) Build() *StringMatching { _, _ = b, x x.xxx_hidden_Hostname = b.Hostname x.xxx_hidden_HostAndPort = b.HostAndPort + x.xxx_hidden_Email = b.Email + x.xxx_hidden_Uuid = b.Uuid return m0 } @@ -796,10 +824,12 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\aip_addr\x18\x01 \x01(\fB\x1d\xbaH\x04z\x02P\x01\xca\xe66\x12\x12\x101234567890123456R\x06ipAddr\x12.\n" + "\tipv4_addr\x18\x02 \x01(\fB\x11\xbaH\x04z\x02X\x01\xca\xe66\x06\x12\x041234R\bipv4Addr\x12:\n" + "\tipv6_addr\x18\x03 \x01(\fB\x1d\xbaH\x04z\x02`\x01\xca\xe66\x12\x12\x101234567890123456R\bipv6Addr\x121\n" + - "\x04uuid\x18\x04 \x01(\fB\x1d\xbaH\x04z\x02x\x01\xca\xe66\x12\x12\x101234567890123456R\x04uuid\"\x8c\x01\n" + + "\x04uuid\x18\x04 \x01(\fB\x1d\xbaH\x04z\x02x\x01\xca\xe66\x12\x12\x101234567890123456R\x04uuid\"\xe2\x01\n" + "\x0eStringMatching\x125\n" + "\bhostname\x18\x01 \x01(\tB\x19\xbaH\x04r\x02h\x01\xca\xe66\x0e\x12\f{domainname}R\bhostname\x12C\n" + - "\rhost_and_port\x18\x02 \x01(\tB\x1f\xbaH\x05r\x03\x80\x02\x01\xca\xe66\x13\x12\x11{domainname}:8080R\vhostAndPort*a\n" + + "\rhost_and_port\x18\x02 \x01(\tB\x1f\xbaH\x05r\x03\x80\x02\x01\xca\xe66\x13\x12\x11{domainname}:8080R\vhostAndPort\x12*\n" + + "\x05email\x18\x03 \x01(\tB\x14\xbaH\x04r\x02`\x01\xca\xe66\t\x12\a{email}R\x05email\x12(\n" + + "\x04uuid\x18\x04 \x01(\tB\x14\xbaH\x05r\x03\xb0\x01\x01\xca\xe66\b\x12\x06{uuid}R\x04uuid*a\n" + "\bTestEnum\x12\x19\n" + "\x15TEST_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eTEST_ENUM_VAL1\x10\x01\x12\x12\n" + diff --git a/native_bytes.go b/native_bytes.go index 793ef472..88f72543 100644 --- a/native_bytes.go +++ b/native_bytes.go @@ -268,139 +268,116 @@ type nativeBytesEval struct { wellKnown *bytesWellKnown } -var ( - errNotUTF8 = errors.New("must be valid UTF-8 to apply regexp") - utf8Violation = &Violation{} //nolint:gochecknoglobals // used for a pointer compare for the special UTF-8 RuntimeError case. -) +var errNotUTF8 = errors.New("must be valid UTF-8 to apply regexp") -type bytesProcessor func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, byteLen uint64) *Violation +//nolint:gocyclo // this code has nested ifs but it's not hard to follow. +func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + bytesVal := val.Bytes() + byteLen := uint64(len(bytesVal)) + var violations []*Violation -//nolint:gochecknoglobals // slice of all the processors that are used, value never modified, effectively immutable -var bytesProcessors = []bytesProcessor{ - // const - func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { - if n.hasConst && !bytes.Equal(bytesVal, n.constVal) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.constDesc, - "bytes.const", fmt.Sprintf("must be %x", n.constVal), - val, protoreflect.ValueOfBytes(n.constVal)) + if n.hasConst && !bytes.Equal(bytesVal, n.constVal) { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.constDesc, + "bytes.const", fmt.Sprintf("must be %x", n.constVal), + val, protoreflect.ValueOfBytes(n.constVal))) + if cfg.failFast { + return &ValidationError{Violations: violations} } - return nil - }, - // len - func(n nativeBytesEval, val protoreflect.Value, _ []byte, byteLen uint64) *Violation { - if n.exactLen != nil && byteLen != *n.exactLen { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.lenDesc, - "bytes.len", fmt.Sprintf("must be %d bytes", *n.exactLen), - val, protoreflect.ValueOfUint64(*n.exactLen)) - } - return nil - }, - // min_len - func(n nativeBytesEval, val protoreflect.Value, _ []byte, byteLen uint64) *Violation { - if byteLen < n.minLen { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.minLenDesc, - "bytes.min_len", fmt.Sprintf("must be at least %d bytes", n.minLen), - val, protoreflect.ValueOfUint64(n.minLen)) - } - return nil - }, - // max_len - func(n nativeBytesEval, val protoreflect.Value, _ []byte, byteLen uint64) *Violation { - if byteLen > n.maxLen { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.maxLenDesc, - "bytes.max_len", fmt.Sprintf("must be at most %d bytes", n.maxLen), - val, protoreflect.ValueOfUint64(n.maxLen)) + } + + if n.exactLen != nil && byteLen != *n.exactLen { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.lenDesc, + "bytes.len", fmt.Sprintf("must be %d bytes", *n.exactLen), + val, protoreflect.ValueOfUint64(*n.exactLen))) + if cfg.failFast { + return &ValidationError{Violations: violations} } - return nil - }, - // pattern (matches against string conversion of bytes) - func(nbe nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { - if nbe.pattern != nil { - if !utf8.Valid(bytesVal) { - // this is ugly, but it's the _only_ case that doesn't follow the pattern. - // we are making a fake violation here that we are going to turn into a RuntimeError - // to satisfy the conformance tests expectation. - return utf8Violation - } + } - if !nbe.pattern.MatchString(string(bytesVal)) { - return nbe.newViolation(bytesDescs.ruleDesc, bytesDescs.patternDesc, - "bytes.pattern", fmt.Sprintf("must match regex pattern `%s`", nbe.patternStr), - val, protoreflect.ValueOfString(nbe.patternStr)) - } + if byteLen < n.minLen { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.minLenDesc, + "bytes.min_len", fmt.Sprintf("must be at least %d bytes", n.minLen), + val, protoreflect.ValueOfUint64(n.minLen))) + if cfg.failFast { + return &ValidationError{Violations: violations} } - return nil - }, - // prefix - func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { - if n.hasPrefix && !bytes.HasPrefix(bytesVal, n.prefix) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.prefixDesc, - "bytes.prefix", fmt.Sprintf("does not have prefix %x", n.prefix), - val, protoreflect.ValueOfBytes(n.prefix)) + } + + if byteLen > n.maxLen { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.maxLenDesc, + "bytes.max_len", fmt.Sprintf("must be at most %d bytes", n.maxLen), + val, protoreflect.ValueOfUint64(n.maxLen))) + if cfg.failFast { + return &ValidationError{Violations: violations} } - return nil - }, - // suffix - func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { - if n.hasSuffix && !bytes.HasSuffix(bytesVal, n.suffix) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.suffixDesc, - "bytes.suffix", fmt.Sprintf("does not have suffix %x", n.suffix), - val, protoreflect.ValueOfBytes(n.suffix)) + } + + if n.pattern != nil { + if !utf8.Valid(bytesVal) { + // the bytes.pattern rule requires the value to be UTF-8. Surface + // this as a RuntimeError to match CEL behavior / conformance tests. + return &RuntimeError{cause: errNotUTF8} } - return nil - }, - // contains - func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { - if n.hasContains && !bytes.Contains(bytesVal, n.contains) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.containsDesc, - "bytes.contains", fmt.Sprintf("does not contain %x", n.contains), - val, protoreflect.ValueOfBytes(n.contains)) + if !n.pattern.MatchString(string(bytesVal)) { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.patternDesc, + "bytes.pattern", fmt.Sprintf("must match regex pattern `%s`", n.patternStr), + val, protoreflect.ValueOfString(n.patternStr))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } } - return nil - }, - // in - func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { - if len(n.inVals) > 0 && !slices.ContainsFunc(n.inVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.inDesc, - "bytes.in", "must be in list "+formatBytesList(n.inVals), - val, protoreflect.ValueOfBytes(bytesVal)) + } + + if n.hasPrefix && !bytes.HasPrefix(bytesVal, n.prefix) { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.prefixDesc, + "bytes.prefix", fmt.Sprintf("does not have prefix %x", n.prefix), + val, protoreflect.ValueOfBytes(n.prefix))) + if cfg.failFast { + return &ValidationError{Violations: violations} } - return nil - }, - // not_in - func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { - if len(n.notInVals) > 0 && slices.ContainsFunc(n.notInVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { - return n.newViolation(bytesDescs.ruleDesc, bytesDescs.notInDesc, - "bytes.not_in", "must not be in list "+formatBytesList(n.notInVals), - val, protoreflect.ValueOfBytes(bytesVal)) + } + + if n.hasSuffix && !bytes.HasSuffix(bytesVal, n.suffix) { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.suffixDesc, + "bytes.suffix", fmt.Sprintf("does not have suffix %x", n.suffix), + val, protoreflect.ValueOfBytes(n.suffix))) + if cfg.failFast { + return &ValidationError{Violations: violations} } - return nil - }, - // well-known format constraints (ip, ipv4, ipv6, uuid) - func(n nativeBytesEval, val protoreflect.Value, bytesVal []byte, _ uint64) *Violation { - if n.wellKnown != nil { - return n.evaluateWellKnown(bytesVal, val) + } + + if n.hasContains && !bytes.Contains(bytesVal, n.contains) { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.containsDesc, + "bytes.contains", fmt.Sprintf("does not contain %x", n.contains), + val, protoreflect.ValueOfBytes(n.contains))) + if cfg.failFast { + return &ValidationError{Violations: violations} } - return nil - }, -} + } -func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - bytesVal := val.Bytes() - byteLen := uint64(len(bytesVal)) - var violations []*Violation + if len(n.inVals) > 0 && !slices.ContainsFunc(n.inVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.inDesc, + "bytes.in", "must be in list "+formatBytesList(n.inVals), + val, protoreflect.ValueOfBytes(bytesVal))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } + } - for _, bytesProcessor := range bytesProcessors { - violation := bytesProcessor(n, val, bytesVal, byteLen) - if violation != nil { - // special case for regex pattern when the bytes aren't a UTF-8 string - if violation == utf8Violation { - return &RuntimeError{cause: errNotUTF8} - } + if len(n.notInVals) > 0 && slices.ContainsFunc(n.notInVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { + violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.notInDesc, + "bytes.not_in", "must not be in list "+formatBytesList(n.notInVals), + val, protoreflect.ValueOfBytes(bytesVal))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } + } - violations = append(violations, violation) + if n.wellKnown != nil { + if v := n.evaluateWellKnown(bytesVal, val); v != nil { + violations = append(violations, v) if cfg.failFast { - break + return &ValidationError{Violations: violations} } } } diff --git a/native_numeric.go b/native_numeric.go index 57d31aa3..9e0f5928 100644 --- a/native_numeric.go +++ b/native_numeric.go @@ -492,99 +492,50 @@ func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protorefle valT := n.config.extractVal(val) var violations []*Violation - // for numerics, since it is using generics, we need to instantiate inside the method. We can't have a slice of - // uninstantiated generic functions in Go. Because it's inside the method, we'll just capture the parameters instead - // of passing them in. - type numericProcessor[T numericValue] func() *Violation - - var numericProcessors = []numericProcessor[T]{ - // const support - func() *Violation { - if n.constVal != nil && valT != *n.constVal { - return n.newViolation(n.config.descs.ruleDesc, n.config.descs.constDesc, - n.config.typeName+".const", - fmt.Sprintf("must equal %v", *n.constVal), - val, n.config.makeRuleVal(*n.constVal)) - } - return nil - }, - // in support - func() *Violation { - if len(n.inVals) > 0 && !slices.Contains(n.inVals, valT) { - return n.newViolation(n.config.descs.ruleDesc, n.config.descs.inDesc, - n.config.typeName+".in", - "must be in list "+formatList(n.inVals), - val, n.config.makeRuleVal(valT)) - } - return nil - }, - // not in support - func() *Violation { - if len(n.notInVals) > 0 && slices.Contains(n.notInVals, valT) { - return n.newViolation(n.config.descs.ruleDesc, n.config.descs.notInDesc, - n.config.typeName+".not_in", - "must not be in list "+formatList(n.notInVals), - val, n.config.makeRuleVal(valT)) - } - return nil - }, - // finite support - func() *Violation { - if n.finite && (math.IsNaN(float64(valT)) || math.IsInf(float64(valT), 0)) { - return n.newViolation(n.config.descs.ruleDesc, n.config.descs.finiteDesc, - n.config.typeName+".finite", - "must be finite", - val, n.config.makeRuleVal(valT)) - } - return nil - }, - // range support - func() *Violation { - if n.lower == lowerBoundNone && n.upper == upperBoundNone { - return nil - } + if n.constVal != nil && valT != *n.constVal { + violations = append(violations, n.newViolation(n.config.descs.ruleDesc, n.config.descs.constDesc, + n.config.typeName+".const", + fmt.Sprintf("must equal %v", *n.constVal), + val, n.config.makeRuleVal(*n.constVal))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } + } - // For float/double, NaN fails all range checks (matches CEL behavior). - isNaN := n.config.nanFailsRange && math.IsNaN(float64(valT)) - - switch { - case n.lower == lowerBoundNone: - if isNaN || n.aboveHi(valT) { - return n.newViolation(n.config.descs.ruleDesc, n.hiDesc(), - n.gtltRule(), "must be "+n.hiMessage(), - val, n.config.makeRuleVal(n.hi)) - } - case n.upper == upperBoundNone: - if isNaN || n.belowLo(valT) { - return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), - n.gtltRule(), "must be "+n.loMessage(), - val, n.config.makeRuleVal(n.lo)) - } - default: - var failure bool - if n.isNormalRange() { - failure = isNaN || n.aboveHi(valT) || n.belowLo(valT) - } else { - failure = isNaN || (n.aboveHi(valT) && n.belowLo(valT)) - } - if failure { - return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), - n.gtltRule(), - fmt.Sprintf("must be %s %s %s", n.loMessage(), n.conjunction(), n.hiMessage()), - val, n.config.makeRuleVal(n.lo)) - } - } - return nil - }, + if len(n.inVals) > 0 && !slices.Contains(n.inVals, valT) { + violations = append(violations, n.newViolation(n.config.descs.ruleDesc, n.config.descs.inDesc, + n.config.typeName+".in", + "must be in list "+formatList(n.inVals), + val, n.config.makeRuleVal(valT))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } } - for _, numericProcessor := range numericProcessors { - violation := numericProcessor() - if violation != nil { - violations = append(violations, violation) - if cfg.failFast { - break - } + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, valT) { + violations = append(violations, n.newViolation(n.config.descs.ruleDesc, n.config.descs.notInDesc, + n.config.typeName+".not_in", + "must not be in list "+formatList(n.notInVals), + val, n.config.makeRuleVal(valT))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } + } + + if n.finite && (math.IsNaN(float64(valT)) || math.IsInf(float64(valT), 0)) { + violations = append(violations, n.newViolation(n.config.descs.ruleDesc, n.config.descs.finiteDesc, + n.config.typeName+".finite", + "must be finite", + val, n.config.makeRuleVal(valT))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } + } + + if v := n.evaluateRange(valT, val); v != nil { + violations = append(violations, v) + if cfg.failFast { + return &ValidationError{Violations: violations} } } @@ -596,6 +547,47 @@ func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protorefle return nil } +// evaluateRange returns a violation for lower/upper bound checks, or nil. +// Split out of Evaluate so that the hot path (no range rules) stays small +// enough to inline. +func (n nativeNumericCompare[T]) evaluateRange(valT T, val protoreflect.Value) *Violation { + if n.lower == lowerBoundNone && n.upper == upperBoundNone { + return nil + } + + // For float/double, NaN fails all range checks (matches CEL behavior). + isNaN := n.config.nanFailsRange && math.IsNaN(float64(valT)) + + switch { + case n.lower == lowerBoundNone: + if isNaN || n.aboveHi(valT) { + return n.newViolation(n.config.descs.ruleDesc, n.hiDesc(), + n.gtltRule(), "must be "+n.hiMessage(), + val, n.config.makeRuleVal(n.hi)) + } + case n.upper == upperBoundNone: + if isNaN || n.belowLo(valT) { + return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), + n.gtltRule(), "must be "+n.loMessage(), + val, n.config.makeRuleVal(n.lo)) + } + default: + var failure bool + if n.isNormalRange() { + failure = isNaN || n.aboveHi(valT) || n.belowLo(valT) + } else { + failure = isNaN || (n.aboveHi(valT) && n.belowLo(valT)) + } + if failure { + return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), + n.gtltRule(), + fmt.Sprintf("must be %s %s %s", n.loMessage(), n.conjunction(), n.hiMessage()), + val, n.config.makeRuleVal(n.lo)) + } + } + return nil +} + func (n nativeNumericCompare[T]) Tautology() bool { return false } diff --git a/native_repeated.go b/native_repeated.go index 01411fe8..2c48639b 100644 --- a/native_repeated.go +++ b/native_repeated.go @@ -15,6 +15,7 @@ package protovalidate import ( + "bytes" "fmt" "math" @@ -54,9 +55,14 @@ func tryNativeRepeatedRules(base base, rules *validate.RepeatedRules) evaluator hasRule = true } - unique := false + var uniqueFn uniqueChecker if rules.GetUnique() { - unique = true + uniqueFn = uniqueCheckerForKind(base.Descriptor) + if uniqueFn == nil { + // message/list/map elements can't be checked for uniqueness + // natively; fall through to CEL. + return nil + } hasRule = true } @@ -68,7 +74,47 @@ func tryNativeRepeatedRules(base base, rules *validate.RepeatedRules) evaluator base: base, minItems: minItems, maxItems: maxItems, - unique: unique, + uniqueFn: uniqueFn, + } +} + +// uniqueChecker tests whether all elements in a repeated list are distinct. +// A nil uniqueChecker means the `unique` rule is not active for this field. +type uniqueChecker func(protoreflect.List) bool + +// uniqueCheckerForKind returns the concrete uniqueness check for the element +// kind of a repeated field, or nil if the kind isn't supported (message, list, +// map — none of which are valid element kinds for a repeated scalar field +// with `unique`). +func uniqueCheckerForKind(desc protoreflect.FieldDescriptor) uniqueChecker { + if desc == nil { + return nil + } + switch desc.Kind() { + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: + return isUniqueList[int32] + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + return isUniqueList[int64] + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + return isUniqueList[uint32] + case protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return isUniqueList[uint64] + case protoreflect.FloatKind: + return isUniqueList[float32] + case protoreflect.DoubleKind: + return isUniqueList[float64] + case protoreflect.StringKind: + return isUniqueList[string] + case protoreflect.BoolKind: + return isUniqueList[bool] + case protoreflect.EnumKind: + return isUniqueList[protoreflect.EnumNumber] + case protoreflect.BytesKind: + return isUniqueBytes + case protoreflect.MessageKind, protoreflect.GroupKind: + return nil + default: + return nil } } @@ -81,43 +127,9 @@ type nativeRepeatedEval struct { base minItems uint64 maxItems uint64 - unique bool -} - -type repeatedProcessor func(n nativeRepeatedEval, val protoreflect.Value, list protoreflect.List, size uint64) *Violation - -//nolint:gochecknoglobals // slice of all the processors that are used, value never modified, effectively immutable -var repeatedProcessors = []repeatedProcessor{ - // min_items - func(n nativeRepeatedEval, val protoreflect.Value, _ protoreflect.List, size uint64) *Violation { - if size < n.minItems { - return n.newViolation(repeatedFieldRulesDesc, repeatedMinItemsDesc, - "repeated.min_items", - fmt.Sprintf("must contain at least %d item(s)", n.minItems), - val, protoreflect.ValueOfUint64(n.minItems)) - } - return nil - }, - // max_items - func(n nativeRepeatedEval, val protoreflect.Value, _ protoreflect.List, size uint64) *Violation { - if size > n.maxItems { - return n.newViolation(repeatedFieldRulesDesc, repeatedMaxItemsDesc, - "repeated.max_items", - fmt.Sprintf("must contain no more than %d item(s)", n.maxItems), - val, protoreflect.ValueOfUint64(n.maxItems)) - } - return nil - }, - // unique - func(n nativeRepeatedEval, val protoreflect.Value, list protoreflect.List, _ uint64) *Violation { - if n.unique && !isUnique(list) { - return n.newViolation(repeatedFieldRulesDesc, repeatedUniqueDesc, - "repeated.unique", - "repeated value must contain unique items", - val, protoreflect.ValueOfBool(true)) - } - return nil - }, + // uniqueFn is nil when the `unique` rule is not active. When set, it is + // specialized for the field's element kind at compile time. + uniqueFn uniqueChecker } func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { @@ -125,13 +137,33 @@ func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Va size := uint64(list.Len()) //nolint:gosec // len can't be < 0 and is always within uint64 range var violations []*Violation - for _, repeatedProcessor := range repeatedProcessors { - violation := repeatedProcessor(n, val, list, size) - if violation != nil { - violations = append(violations, violation) - if cfg.failFast { - break - } + if size < n.minItems { + violations = append(violations, n.newViolation(repeatedFieldRulesDesc, repeatedMinItemsDesc, + "repeated.min_items", + fmt.Sprintf("must contain at least %d item(s)", n.minItems), + val, protoreflect.ValueOfUint64(n.minItems))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } + } + + if size > n.maxItems { + violations = append(violations, n.newViolation(repeatedFieldRulesDesc, repeatedMaxItemsDesc, + "repeated.max_items", + fmt.Sprintf("must contain no more than %d item(s)", n.maxItems), + val, protoreflect.ValueOfUint64(n.maxItems))) + if cfg.failFast { + return &ValidationError{Violations: violations} + } + } + + if n.uniqueFn != nil && !n.uniqueFn(list) { + violations = append(violations, n.newViolation(repeatedFieldRulesDesc, repeatedUniqueDesc, + "repeated.unique", + "repeated value must contain unique items", + val, protoreflect.ValueOfBool(true))) + if cfg.failFast { + return &ValidationError{Violations: violations} } } @@ -143,40 +175,37 @@ func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Va return nil } -// isUnique checks whether all elements in the list are distinct. -func isUnique(list protoreflect.List) bool { +// uniqueLinearThreshold is the list length at and below which uniqueness is +// checked with an O(n²) scan over a stack-allocated array instead of a map. +// For small lists the linear scan is faster and avoids the map allocation; +// at larger sizes the map's O(n) lookup wins. +const uniqueLinearThreshold = 16 + +// isUniqueList is the generic uniqueness check used for all comparable scalar +// element kinds (the concrete T is bound at compile time via +// uniqueCheckerForKind). +func isUniqueList[T comparable](list protoreflect.List) bool { length := list.Len() if length <= 1 { return true } - // type-specific maps avoid any-boxing allocations - switch list.Get(0).Interface().(type) { - case int32: - return isUniqueTyped[int32](list, length) - case int64: - return isUniqueTyped[int64](list, length) - case uint32: - return isUniqueTyped[uint32](list, length) - case uint64: - return isUniqueTyped[uint64](list, length) - case float32: - return isUniqueTyped[float32](list, length) - case float64: - return isUniqueTyped[float64](list, length) - case string: - return isUniqueTyped[string](list, length) - case bool: - return isUniqueTyped[bool](list, length) - case protoreflect.EnumNumber: - return isUniqueTyped[protoreflect.EnumNumber](list, length) - case []byte: - return isUniqueBytes(list, length) - default: - return false // message, list, and map types are not supported, only enum and scalars + if length <= uniqueLinearThreshold { + var seen = make([]T, length) + for i := range length { + key, ok := list.Get(i).Interface().(T) + if !ok { + return false + } + for j := range i { + if seen[j] == key { + return false + } + } + seen[i] = key + } + return true } -} -func isUniqueTyped[T comparable](list protoreflect.List, length int) bool { seen := make(map[T]struct{}, length) for i := range length { key, ok := list.Get(i).Interface().(T) @@ -192,7 +221,27 @@ func isUniqueTyped[T comparable](list protoreflect.List, length int) bool { return true } -func isUniqueBytes(list protoreflect.List, length int) bool { +func isUniqueBytes(list protoreflect.List) bool { + length := list.Len() + if length <= 1 { + return true + } + if length <= uniqueLinearThreshold { + // storing []byte directly avoids the []byte→string allocation the + // map path needs for a hashable key. + var seen = make([][]byte, uniqueLinearThreshold) + for i := range length { + byteVal := list.Get(i).Bytes() + for j := range i { + if bytes.Equal(seen[j], byteVal) { + return false + } + } + seen[i] = byteVal + } + return true + } + seen := make(map[string]struct{}, length) for i := range length { byteVal := list.Get(i).Bytes() diff --git a/native_repeated_test.go b/native_repeated_test.go index 2471ddee..d1788dd7 100644 --- a/native_repeated_test.go +++ b/native_repeated_test.go @@ -257,3 +257,78 @@ func newInt32List(t testing.TB, vals ...int32) protoreflect.List { } return list.List() } + +type testList[T any] struct { + protoreflect.List + vals []T +} + +// These are the only methods we care about. +func (l testList[T]) Len() int { + return len(l.vals) +} + +func (l testList[T]) Get(i int) protoreflect.Value { + return protoreflect.ValueOf(l.vals[i]) +} + +func Test_isUniqueList(t *testing.T) { + t.Parallel() + // try lengths of 0, 1, 10, 16, and 20 + // don't care about type, so just use int + // need both positive and negative tests + data := []struct { + name string + vals []int64 + result bool + }{ + {"empty", []int64{}, true}, + {"single", []int64{1}, true}, + {"10", []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, true}, + {"16", []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, true}, + {"20", []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, true}, + {"10_dup", []int64{1, 2, 3, 4, 10, 6, 7, 8, 9, 10}, false}, + {"16", []int64{1, 2, 3, 4, 5, 6, 16, 8, 9, 10, 11, 12, 13, 14, 15, 16}, false}, + {"20", []int64{1, 2, 3, 4, 5, 6, 7, 20, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20}, false}, + } + for _, d := range data { + t.Run(d.name, func(t *testing.T) { + t.Parallel() + l := testList[int64]{ + vals: d.vals, + } + result := isUniqueList[int64](l) + assert.Equal(t, d.result, result) + }) + } +} + +func Test_isUniqueBytes(t *testing.T) { + t.Parallel() + // try lengths of 0, 1, 3, 16, and 20 + // need both positive and negative tests + data := []struct { + name string + vals [][]byte + result bool + }{ + {"empty", [][]byte{}, true}, + {"single", [][]byte{[]byte("1")}, true}, + {"3", [][]byte{[]byte("1"), []byte("2"), []byte("3")}, true}, + {"16", [][]byte{[]byte("1"), []byte("2"), []byte("3"), []byte("4"), []byte("5"), []byte("6"), []byte("7"), []byte("8"), []byte("9"), []byte("10"), []byte("11"), []byte("12"), []byte("13"), []byte("14"), []byte("15"), []byte("16")}, true}, + {"20_dup", [][]byte{[]byte("1"), []byte("2"), []byte("3"), []byte("4"), []byte("5"), []byte("6"), []byte("7"), []byte("8"), []byte("9"), []byte("10"), []byte("11"), []byte("12"), []byte("13"), []byte("14"), []byte("15"), []byte("16"), []byte("17"), []byte("18"), []byte("19"), []byte("20")}, true}, + {"3_dup", [][]byte{[]byte("1"), []byte("2"), []byte("1")}, false}, + {"16_dup", [][]byte{[]byte("1"), []byte("2"), []byte("3"), []byte("4"), []byte("5"), []byte("6"), []byte("16"), []byte("8"), []byte("9"), []byte("10"), []byte("11"), []byte("12"), []byte("13"), []byte("14"), []byte("15"), []byte("16")}, false}, + {"20_dup", [][]byte{[]byte("1"), []byte("2"), []byte("3"), []byte("4"), []byte("5"), []byte("6"), []byte("20"), []byte("8"), []byte("9"), []byte("10"), []byte("11"), []byte("12"), []byte("13"), []byte("14"), []byte("15"), []byte("16"), []byte("17"), []byte("18"), []byte("19"), []byte("20")}, false}, + } + for _, d := range data { + t.Run(d.name, func(t *testing.T) { + t.Parallel() + l := testList[[]byte]{ + vals: d.vals, + } + result := isUniqueBytes(l) + assert.Equal(t, d.result, result) + }) + } +} diff --git a/native_string.go b/native_string.go index 7edcf607..d238c2f0 100644 --- a/native_string.go +++ b/native_string.go @@ -446,123 +446,116 @@ type nativeStringEval struct { strict bool } -type stringProcessor func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation - -//nolint:gochecknoglobals // slice of all the processors that are used, value never modified, effectively immutable -var stringProcessors = []stringProcessor{ - // rune length - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - var runeCount uint64 - if n.exactLen != nil || n.minLen != nil || n.maxLen != nil { - runeCount = uint64(utf8.RuneCountInString(strVal)) - return n.evaluateLength(runeCount, val) - } - return nil - }, - // byte length - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - var byteCount uint64 - if n.exactBytes != nil || n.minBytes != nil || n.maxBytes != nil { - byteCount = uint64(len(strVal)) - return n.evaluateByteLength(byteCount, val) - } - return nil - }, - // const - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if n.constVal != nil && strVal != *n.constVal { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.constDesc, - "string.const", fmt.Sprintf("must equal `%s`", *n.constVal), - val, protoreflect.ValueOfString(*n.constVal))} +//nolint:gocyclo // this code has nested ifs but it's not hard to follow. +func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + strVal := val.String() + var violations []*Violation + + if n.exactLen != nil || n.minLen != nil || n.maxLen != nil { + runeCount := uint64(utf8.RuneCountInString(strVal)) //nolint:gosec // cannot be negative + if vs := n.evaluateLength(runeCount, val); len(vs) > 0 { + violations = append(violations, vs...) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} + } } - return nil - }, - // pattern - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if n.pattern != nil && !n.pattern.MatchString(strVal) { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.patternDesc, - "string.pattern", fmt.Sprintf("does not match regex pattern `%s`", n.patternStr), - val, protoreflect.ValueOfString(n.patternStr))} + } + + if n.exactBytes != nil || n.minBytes != nil || n.maxBytes != nil { + byteCount := uint64(len(strVal)) + if vs := n.evaluateByteLength(byteCount, val); len(vs) > 0 { + violations = append(violations, vs...) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} + } } - return nil - }, - // prefix - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if n.prefix != nil && !strings.HasPrefix(strVal, *n.prefix) { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.prefixDesc, - "string.prefix", fmt.Sprintf("does not have prefix `%s`", *n.prefix), - val, protoreflect.ValueOfString(*n.prefix))} + } + + if n.constVal != nil && strVal != *n.constVal { + violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.constDesc, + "string.const", fmt.Sprintf("must equal `%s`", *n.constVal), + val, protoreflect.ValueOfString(*n.constVal))) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} } - return nil - }, - // suffix - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if n.suffix != nil && !strings.HasSuffix(strVal, *n.suffix) { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.suffixDesc, - "string.suffix", fmt.Sprintf("does not have suffix `%s`", *n.suffix), - val, protoreflect.ValueOfString(*n.suffix))} + } + + if n.pattern != nil && !n.pattern.MatchString(strVal) { + violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.patternDesc, + "string.pattern", fmt.Sprintf("does not match regex pattern `%s`", n.patternStr), + val, protoreflect.ValueOfString(n.patternStr))) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} } - return nil - }, - // contains - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if n.contains != nil && !strings.Contains(strVal, *n.contains) { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.containsDesc, - "string.contains", fmt.Sprintf("does not contain substring `%s`", *n.contains), - val, protoreflect.ValueOfString(*n.contains))} + } + + if n.prefix != nil && !strings.HasPrefix(strVal, *n.prefix) { + violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.prefixDesc, + "string.prefix", fmt.Sprintf("does not have prefix `%s`", *n.prefix), + val, protoreflect.ValueOfString(*n.prefix))) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} } - return nil - }, - // not_contains - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if n.notContains != nil && strings.Contains(strVal, *n.notContains) { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.notContainsDesc, - "string.not_contains", fmt.Sprintf("value contains substring `%s`", *n.notContains), - val, protoreflect.ValueOfString(*n.notContains))} + } + + if n.suffix != nil && !strings.HasSuffix(strVal, *n.suffix) { + violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.suffixDesc, + "string.suffix", fmt.Sprintf("does not have suffix `%s`", *n.suffix), + val, protoreflect.ValueOfString(*n.suffix))) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} } - return nil - }, - // in - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if len(n.inVals) > 0 && !slices.Contains(n.inVals, strVal) { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.inDesc, - "string.in", "must be in list "+formatStringList(n.inVals), - val, protoreflect.ValueOfString(strVal))} + } + + if n.contains != nil && !strings.Contains(strVal, *n.contains) { + violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.containsDesc, + "string.contains", fmt.Sprintf("does not contain substring `%s`", *n.contains), + val, protoreflect.ValueOfString(*n.contains))) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} } - return nil - }, - // not_in - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if len(n.notInVals) > 0 && slices.Contains(n.notInVals, strVal) { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.notInDesc, - "string.not_in", "must not be in list "+formatStringList(n.notInVals), - val, protoreflect.ValueOfString(strVal))} + } + + if n.notContains != nil && strings.Contains(strVal, *n.notContains) { + violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.notContainsDesc, + "string.not_contains", fmt.Sprintf("value contains substring `%s`", *n.notContains), + val, protoreflect.ValueOfString(*n.notContains))) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} } - return nil - }, - // well known rules - func(n nativeStringEval, val protoreflect.Value, strVal string) []*Violation { - if n.wellKnownRule != nil { - return n.checkWellKnown(strVal, val) - } else if n.knownRegex != 0 { - return n.checkKnownRegex(strVal, val) + } + + if len(n.inVals) > 0 && !slices.Contains(n.inVals, strVal) { + violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.inDesc, + "string.in", "must be in list "+formatStringList(n.inVals), + val, protoreflect.ValueOfString(strVal))) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} } - return nil - }, -} + } -func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { - strVal := val.String() - var violations []*Violation + if len(n.notInVals) > 0 && slices.Contains(n.notInVals, strVal) { + violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.notInDesc, + "string.not_in", "must not be in list "+formatStringList(n.notInVals), + val, protoreflect.ValueOfString(strVal))) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} + } + } - for _, stringProcessor := range stringProcessors { - violation := stringProcessor(n, val, strVal) - if len(violation) != 0 { - violations = append(violations, violation...) + //nolint:nestif // there are levels of nested ifs, but it's not hard to follow. + if n.wellKnownRule != nil { + if vs := n.checkWellKnown(strVal, val); len(vs) > 0 { + violations = append(violations, vs...) + if cfg.failFast { + return &ValidationError{Violations: violations[:1]} + } + } + } else if n.knownRegex != 0 { + if vs := n.checkKnownRegex(strVal, val); len(vs) > 0 { + violations = append(violations, vs...) if cfg.failFast { - // only return the first violation - violations = violations[:1] - break + return &ValidationError{Violations: violations[:1]} } } } diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto index bd0c7977..577c4f47 100644 --- a/proto/tests/example/v1/native_test.proto +++ b/proto/tests/example/v1/native_test.proto @@ -177,4 +177,12 @@ message StringMatching { (buf.validate.field).string.host_and_port = true, (gofakeit.generate).tag = "{domainname}:8080" ]; + string email = 3 [ + (buf.validate.field).string.email = true, + (gofakeit.generate).tag = "{email}" + ]; + string uuid = 4 [ + (buf.validate.field).string.uuid = true, + (gofakeit.generate).tag = "{uuid}" + ]; } diff --git a/validator_bench_test.go b/validator_bench_test.go index 0176d78d..f0517724 100644 --- a/validator_bench_test.go +++ b/validator_bench_test.go @@ -63,6 +63,10 @@ func BenchmarkTestByteMatching(b *testing.B) { benchSuccess(b, &pb.TestByteMatching{}) } +func BenchmarkStringMatching(b *testing.B) { + benchSuccess(b, &pb.StringMatching{}) +} + func TestInt32GT(t *testing.T) { t.Parallel() testSuccess(t, &pb.BenchGT{}) From c04b9982e2ff8bd6d5ff20aa007254b7fd9b4be0 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 23 Apr 2026 18:08:15 -0400 Subject: [PATCH 10/14] add google WKT wrapper support --- builder.go | 62 ++-- .../gen/tests/example/v1/native_test.pb.go | 339 +++++++++++++++++- .../example/v1/native_test_protoopaque.pb.go | 339 +++++++++++++++++- proto/tests/example/v1/native_test.proto | 31 ++ validator_bench_test.go | 46 +++ wrapper.go | 42 +++ 6 files changed, 797 insertions(+), 62 deletions(-) create mode 100644 wrapper.go diff --git a/builder.go b/builder.go index 45034482..d4bdea39 100644 --- a/builder.go +++ b/builder.go @@ -520,55 +520,61 @@ func (bldr *builder) tryNativeRules( if rules == nil { return nil } - // When processWrapperRules unwraps a wrapper type (e.g., Int32Value), - // it calls buildValue with the inner scalar field descriptor but - // attaches the resulting evaluators to the outer value. At runtime, - // the evaluator receives the wrapper message, not the inner scalar, - // so native evaluators that call val.Int() would panic. The outer - // value's Descriptor still points to the wrapper message field, so - // we use its Kind to detect this case and fall back to CEL. - if valEval.Descriptor != nil && - (valEval.Descriptor.Kind() == protoreflect.MessageKind || - valEval.Descriptor.Kind() == protoreflect.GroupKind) { - return nil - } base := newBase(valEval) + var native evaluator switch fdesc.Kind() { case protoreflect.Int32Kind: - return tryBuildNativeInt32Rules(base, rules.GetInt32()) + native = tryBuildNativeInt32Rules(base, rules.GetInt32()) case protoreflect.Sint32Kind: - return tryBuildNativeSint32Rules(base, rules.GetSint32()) + native = tryBuildNativeSint32Rules(base, rules.GetSint32()) case protoreflect.Sfixed32Kind: - return tryBuildNativeSfixed32Rules(base, rules.GetSfixed32()) + native = tryBuildNativeSfixed32Rules(base, rules.GetSfixed32()) case protoreflect.Int64Kind: - return tryBuildNativeInt64Rules(base, rules.GetInt64()) + native = tryBuildNativeInt64Rules(base, rules.GetInt64()) case protoreflect.Sint64Kind: - return tryBuildNativeSint64Rules(base, rules.GetSint64()) + native = tryBuildNativeSint64Rules(base, rules.GetSint64()) case protoreflect.Sfixed64Kind: - return tryBuildNativeSfixed64Rules(base, rules.GetSfixed64()) + native = tryBuildNativeSfixed64Rules(base, rules.GetSfixed64()) case protoreflect.Uint32Kind: - return tryBuildNativeUint32Rules(base, rules.GetUint32()) + native = tryBuildNativeUint32Rules(base, rules.GetUint32()) case protoreflect.Fixed32Kind: - return tryBuildNativeFixed32Rules(base, rules.GetFixed32()) + native = tryBuildNativeFixed32Rules(base, rules.GetFixed32()) case protoreflect.Uint64Kind: - return tryBuildNativeUint64Rules(base, rules.GetUint64()) + native = tryBuildNativeUint64Rules(base, rules.GetUint64()) case protoreflect.Fixed64Kind: - return tryBuildNativeFixed64Rules(base, rules.GetFixed64()) + native = tryBuildNativeFixed64Rules(base, rules.GetFixed64()) case protoreflect.FloatKind: - return tryBuildNativeFloatRules(base, rules.GetFloat()) + native = tryBuildNativeFloatRules(base, rules.GetFloat()) case protoreflect.DoubleKind: - return tryBuildNativeDoubleRules(base, rules.GetDouble()) + native = tryBuildNativeDoubleRules(base, rules.GetDouble()) case protoreflect.StringKind: - return tryBuildNativeStringRules(base, rules.GetString()) + native = tryBuildNativeStringRules(base, rules.GetString()) case protoreflect.BoolKind: - return tryBuildNativeBoolRules(base, rules.GetBool()) + native = tryBuildNativeBoolRules(base, rules.GetBool()) case protoreflect.EnumKind: - return tryBuildNativeEnumRules(base, rules.GetEnum()) + native = tryBuildNativeEnumRules(base, rules.GetEnum()) case protoreflect.BytesKind: - return tryBuildNativeBytesRules(base, rules.GetBytes()) + native = tryBuildNativeBytesRules(base, rules.GetBytes()) default: return nil } + if native == nil { + return nil + } + // processWrapperRules swaps in the inner "value" field as fdesc when + // building rules for a wrapper WKT (Int32Value, StringValue, ...), but + // leaves valEval.Descriptor pointing at the outer wrapper message field. + // Detect that here and wrap the native eval so it unwraps the wrapper + // message at runtime before calling val.Int()/Bytes()/etc. + if valEval.Descriptor != nil && + (valEval.Descriptor.Kind() == protoreflect.MessageKind || + valEval.Descriptor.Kind() == protoreflect.GroupKind) { + native = wrappedValueEval{ + innerField: fdesc, + inner: native, + } + } + return native } func (bldr *builder) processAnyRules( diff --git a/internal/gen/tests/example/v1/native_test.pb.go b/internal/gen/tests/example/v1/native_test.pb.go index 4f816873..366d1a38 100644 --- a/internal/gen/tests/example/v1/native_test.pb.go +++ b/internal/gen/tests/example/v1/native_test.pb.go @@ -27,6 +27,7 @@ import ( _ "github.com/rodaine/protogofakeit/gen/gofakeit" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + wrapperspb "google.golang.org/protobuf/types/known/wrapperspb" reflect "reflect" unsafe "unsafe" ) @@ -784,11 +785,279 @@ func (b0 StringMatching_builder) Build() *StringMatching { return m0 } +type WrapperTesting struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + I32 *wrapperspb.Int32Value `protobuf:"bytes,1,opt,name=i32,proto3" json:"i32,omitempty"` + D *wrapperspb.DoubleValue `protobuf:"bytes,2,opt,name=d,proto3" json:"d,omitempty"` + F *wrapperspb.FloatValue `protobuf:"bytes,3,opt,name=f,proto3" json:"f,omitempty"` + I64 *wrapperspb.Int64Value `protobuf:"bytes,4,opt,name=i64,proto3" json:"i64,omitempty"` + U64 *wrapperspb.UInt64Value `protobuf:"bytes,5,opt,name=u64,proto3" json:"u64,omitempty"` + U32 *wrapperspb.UInt32Value `protobuf:"bytes,6,opt,name=u32,proto3" json:"u32,omitempty"` + B *wrapperspb.BoolValue `protobuf:"bytes,7,opt,name=b,proto3" json:"b,omitempty"` + S *wrapperspb.StringValue `protobuf:"bytes,8,opt,name=s,proto3" json:"s,omitempty"` + Bs *wrapperspb.BytesValue `protobuf:"bytes,9,opt,name=bs,proto3" json:"bs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WrapperTesting) Reset() { + *x = WrapperTesting{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WrapperTesting) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WrapperTesting) ProtoMessage() {} + +func (x *WrapperTesting) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *WrapperTesting) GetI32() *wrapperspb.Int32Value { + if x != nil { + return x.I32 + } + return nil +} + +func (x *WrapperTesting) GetD() *wrapperspb.DoubleValue { + if x != nil { + return x.D + } + return nil +} + +func (x *WrapperTesting) GetF() *wrapperspb.FloatValue { + if x != nil { + return x.F + } + return nil +} + +func (x *WrapperTesting) GetI64() *wrapperspb.Int64Value { + if x != nil { + return x.I64 + } + return nil +} + +func (x *WrapperTesting) GetU64() *wrapperspb.UInt64Value { + if x != nil { + return x.U64 + } + return nil +} + +func (x *WrapperTesting) GetU32() *wrapperspb.UInt32Value { + if x != nil { + return x.U32 + } + return nil +} + +func (x *WrapperTesting) GetB() *wrapperspb.BoolValue { + if x != nil { + return x.B + } + return nil +} + +func (x *WrapperTesting) GetS() *wrapperspb.StringValue { + if x != nil { + return x.S + } + return nil +} + +func (x *WrapperTesting) GetBs() *wrapperspb.BytesValue { + if x != nil { + return x.Bs + } + return nil +} + +func (x *WrapperTesting) SetI32(v *wrapperspb.Int32Value) { + x.I32 = v +} + +func (x *WrapperTesting) SetD(v *wrapperspb.DoubleValue) { + x.D = v +} + +func (x *WrapperTesting) SetF(v *wrapperspb.FloatValue) { + x.F = v +} + +func (x *WrapperTesting) SetI64(v *wrapperspb.Int64Value) { + x.I64 = v +} + +func (x *WrapperTesting) SetU64(v *wrapperspb.UInt64Value) { + x.U64 = v +} + +func (x *WrapperTesting) SetU32(v *wrapperspb.UInt32Value) { + x.U32 = v +} + +func (x *WrapperTesting) SetB(v *wrapperspb.BoolValue) { + x.B = v +} + +func (x *WrapperTesting) SetS(v *wrapperspb.StringValue) { + x.S = v +} + +func (x *WrapperTesting) SetBs(v *wrapperspb.BytesValue) { + x.Bs = v +} + +func (x *WrapperTesting) HasI32() bool { + if x == nil { + return false + } + return x.I32 != nil +} + +func (x *WrapperTesting) HasD() bool { + if x == nil { + return false + } + return x.D != nil +} + +func (x *WrapperTesting) HasF() bool { + if x == nil { + return false + } + return x.F != nil +} + +func (x *WrapperTesting) HasI64() bool { + if x == nil { + return false + } + return x.I64 != nil +} + +func (x *WrapperTesting) HasU64() bool { + if x == nil { + return false + } + return x.U64 != nil +} + +func (x *WrapperTesting) HasU32() bool { + if x == nil { + return false + } + return x.U32 != nil +} + +func (x *WrapperTesting) HasB() bool { + if x == nil { + return false + } + return x.B != nil +} + +func (x *WrapperTesting) HasS() bool { + if x == nil { + return false + } + return x.S != nil +} + +func (x *WrapperTesting) HasBs() bool { + if x == nil { + return false + } + return x.Bs != nil +} + +func (x *WrapperTesting) ClearI32() { + x.I32 = nil +} + +func (x *WrapperTesting) ClearD() { + x.D = nil +} + +func (x *WrapperTesting) ClearF() { + x.F = nil +} + +func (x *WrapperTesting) ClearI64() { + x.I64 = nil +} + +func (x *WrapperTesting) ClearU64() { + x.U64 = nil +} + +func (x *WrapperTesting) ClearU32() { + x.U32 = nil +} + +func (x *WrapperTesting) ClearB() { + x.B = nil +} + +func (x *WrapperTesting) ClearS() { + x.S = nil +} + +func (x *WrapperTesting) ClearBs() { + x.Bs = nil +} + +type WrapperTesting_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + I32 *wrapperspb.Int32Value + D *wrapperspb.DoubleValue + F *wrapperspb.FloatValue + I64 *wrapperspb.Int64Value + U64 *wrapperspb.UInt64Value + U32 *wrapperspb.UInt32Value + B *wrapperspb.BoolValue + S *wrapperspb.StringValue + Bs *wrapperspb.BytesValue +} + +func (b0 WrapperTesting_builder) Build() *WrapperTesting { + m0 := &WrapperTesting{} + b, x := &b0, m0 + _, _ = b, x + x.I32 = b.I32 + x.D = b.D + x.F = b.F + x.I64 = b.I64 + x.U64 = b.U64 + x.U32 = b.U32 + x.B = b.B + x.S = b.S + x.Bs = b.Bs + return m0 +} + var File_tests_example_v1_native_test_proto protoreflect.FileDescriptor const file_tests_example_v1_native_test_proto_rawDesc = "" + "\n" + - "\"tests/example/v1/native_test.proto\x12\x10tests.example.v1\x1a\x1bbuf/validate/validate.proto\x1a\x17gofakeit/gofakeit.proto\"\xb0\a\n" + + "\"tests/example/v1/native_test.proto\x12\x10tests.example.v1\x1a\x1bbuf/validate/validate.proto\x1a\x17gofakeit/gofakeit.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\xb0\a\n" + "\aBenchGT\x12-\n" + "\x02gt\x18\x01 \x01(\x05B\x1d\xbaH\x04\x1a\x02 \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x02gt\x12/\n" + "\x03gte\x18\x02 \x01(\x05B\x1d\xbaH\x04\x1a\x02(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x03gte\x12-\n" + @@ -829,7 +1098,24 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\bhostname\x18\x01 \x01(\tB\x19\xbaH\x04r\x02h\x01\xca\xe66\x0e\x12\f{domainname}R\bhostname\x12C\n" + "\rhost_and_port\x18\x02 \x01(\tB\x1f\xbaH\x05r\x03\x80\x02\x01\xca\xe66\x13\x12\x11{domainname}:8080R\vhostAndPort\x12*\n" + "\x05email\x18\x03 \x01(\tB\x14\xbaH\x04r\x02`\x01\xca\xe66\t\x12\a{email}R\x05email\x12(\n" + - "\x04uuid\x18\x04 \x01(\tB\x14\xbaH\x05r\x03\xb0\x01\x01\xca\xe66\b\x12\x06{uuid}R\x04uuid*a\n" + + "\x04uuid\x18\x04 \x01(\tB\x14\xbaH\x05r\x03\xb0\x01\x01\xca\xe66\b\x12\x06{uuid}R\x04uuid\"\x88\x04\n" + + "\x0eWrapperTesting\x126\n" + + "\x03i32\x18\x01 \x01(\v2\x1b.google.protobuf.Int32ValueB\a\xbaH\x04\x1a\x02 \n" + + "R\x03i32\x12:\n" + + "\x01d\x18\x02 \x01(\v2\x1c.google.protobuf.DoubleValueB\x0e\xbaH\v\x12\t!\x00\x00\x00\x00\x00\x00$@R\x01d\x125\n" + + "\x01f\x18\x03 \x01(\v2\x1b.google.protobuf.FloatValueB\n" + + "\xbaH\a\n" + + "\x05%\x00\x00 AR\x01f\x126\n" + + "\x03i64\x18\x04 \x01(\v2\x1b.google.protobuf.Int64ValueB\a\xbaH\x04\"\x02 \n" + + "R\x03i64\x127\n" + + "\x03u64\x18\x05 \x01(\v2\x1c.google.protobuf.UInt64ValueB\a\xbaH\x042\x02 \n" + + "R\x03u64\x127\n" + + "\x03u32\x18\x06 \x01(\v2\x1c.google.protobuf.UInt32ValueB\a\xbaH\x04*\x02 \n" + + "R\x03u32\x121\n" + + "\x01b\x18\a \x01(\v2\x1a.google.protobuf.BoolValueB\a\xbaH\x04j\x02\b\x01R\x01b\x128\n" + + "\x01s\x18\b \x01(\v2\x1c.google.protobuf.StringValueB\f\xbaH\tr\a\n" + + "\x05helloR\x01s\x124\n" + + "\x02bs\x18\t \x01(\v2\x1b.google.protobuf.BytesValueB\a\xbaH\x04z\x02h\x05R\x02bs*a\n" + "\bTestEnum\x12\x19\n" + "\x15TEST_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eTEST_ENUM_VAL1\x10\x01\x12\x12\n" + @@ -838,23 +1124,42 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\x14com.tests.example.v1B\x0fNativeTestProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" var file_tests_example_v1_native_test_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_tests_example_v1_native_test_proto_goTypes = []any{ - (TestEnum)(0), // 0: tests.example.v1.TestEnum - (*BenchGT)(nil), // 1: tests.example.v1.BenchGT - (*BenchTestBytes)(nil), // 2: tests.example.v1.BenchTestBytes - (*TestUnique)(nil), // 3: tests.example.v1.TestUnique - (*TestByteBroken)(nil), // 4: tests.example.v1.TestByteBroken - (*TestByteMatching)(nil), // 5: tests.example.v1.TestByteMatching - (*StringMatching)(nil), // 6: tests.example.v1.StringMatching + (TestEnum)(0), // 0: tests.example.v1.TestEnum + (*BenchGT)(nil), // 1: tests.example.v1.BenchGT + (*BenchTestBytes)(nil), // 2: tests.example.v1.BenchTestBytes + (*TestUnique)(nil), // 3: tests.example.v1.TestUnique + (*TestByteBroken)(nil), // 4: tests.example.v1.TestByteBroken + (*TestByteMatching)(nil), // 5: tests.example.v1.TestByteMatching + (*StringMatching)(nil), // 6: tests.example.v1.StringMatching + (*WrapperTesting)(nil), // 7: tests.example.v1.WrapperTesting + (*wrapperspb.Int32Value)(nil), // 8: google.protobuf.Int32Value + (*wrapperspb.DoubleValue)(nil), // 9: google.protobuf.DoubleValue + (*wrapperspb.FloatValue)(nil), // 10: google.protobuf.FloatValue + (*wrapperspb.Int64Value)(nil), // 11: google.protobuf.Int64Value + (*wrapperspb.UInt64Value)(nil), // 12: google.protobuf.UInt64Value + (*wrapperspb.UInt32Value)(nil), // 13: google.protobuf.UInt32Value + (*wrapperspb.BoolValue)(nil), // 14: google.protobuf.BoolValue + (*wrapperspb.StringValue)(nil), // 15: google.protobuf.StringValue + (*wrapperspb.BytesValue)(nil), // 16: google.protobuf.BytesValue } var file_tests_example_v1_native_test_proto_depIdxs = []int32{ - 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum + 8, // 1: tests.example.v1.WrapperTesting.i32:type_name -> google.protobuf.Int32Value + 9, // 2: tests.example.v1.WrapperTesting.d:type_name -> google.protobuf.DoubleValue + 10, // 3: tests.example.v1.WrapperTesting.f:type_name -> google.protobuf.FloatValue + 11, // 4: tests.example.v1.WrapperTesting.i64:type_name -> google.protobuf.Int64Value + 12, // 5: tests.example.v1.WrapperTesting.u64:type_name -> google.protobuf.UInt64Value + 13, // 6: tests.example.v1.WrapperTesting.u32:type_name -> google.protobuf.UInt32Value + 14, // 7: tests.example.v1.WrapperTesting.b:type_name -> google.protobuf.BoolValue + 15, // 8: tests.example.v1.WrapperTesting.s:type_name -> google.protobuf.StringValue + 16, // 9: tests.example.v1.WrapperTesting.bs:type_name -> google.protobuf.BytesValue + 10, // [10:10] is the sub-list for method output_type + 10, // [10:10] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_tests_example_v1_native_test_proto_init() } @@ -868,7 +1173,7 @@ func file_tests_example_v1_native_test_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_native_test_proto_rawDesc), len(file_tests_example_v1_native_test_proto_rawDesc)), NumEnums: 1, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go index f3670a55..4ab02c7d 100644 --- a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go +++ b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go @@ -27,6 +27,7 @@ import ( _ "github.com/rodaine/protogofakeit/gen/gofakeit" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + wrapperspb "google.golang.org/protobuf/types/known/wrapperspb" reflect "reflect" unsafe "unsafe" ) @@ -784,11 +785,279 @@ func (b0 StringMatching_builder) Build() *StringMatching { return m0 } +type WrapperTesting struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_I32 *wrapperspb.Int32Value `protobuf:"bytes,1,opt,name=i32,proto3"` + xxx_hidden_D *wrapperspb.DoubleValue `protobuf:"bytes,2,opt,name=d,proto3"` + xxx_hidden_F *wrapperspb.FloatValue `protobuf:"bytes,3,opt,name=f,proto3"` + xxx_hidden_I64 *wrapperspb.Int64Value `protobuf:"bytes,4,opt,name=i64,proto3"` + xxx_hidden_U64 *wrapperspb.UInt64Value `protobuf:"bytes,5,opt,name=u64,proto3"` + xxx_hidden_U32 *wrapperspb.UInt32Value `protobuf:"bytes,6,opt,name=u32,proto3"` + xxx_hidden_B *wrapperspb.BoolValue `protobuf:"bytes,7,opt,name=b,proto3"` + xxx_hidden_S *wrapperspb.StringValue `protobuf:"bytes,8,opt,name=s,proto3"` + xxx_hidden_Bs *wrapperspb.BytesValue `protobuf:"bytes,9,opt,name=bs,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WrapperTesting) Reset() { + *x = WrapperTesting{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WrapperTesting) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WrapperTesting) ProtoMessage() {} + +func (x *WrapperTesting) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *WrapperTesting) GetI32() *wrapperspb.Int32Value { + if x != nil { + return x.xxx_hidden_I32 + } + return nil +} + +func (x *WrapperTesting) GetD() *wrapperspb.DoubleValue { + if x != nil { + return x.xxx_hidden_D + } + return nil +} + +func (x *WrapperTesting) GetF() *wrapperspb.FloatValue { + if x != nil { + return x.xxx_hidden_F + } + return nil +} + +func (x *WrapperTesting) GetI64() *wrapperspb.Int64Value { + if x != nil { + return x.xxx_hidden_I64 + } + return nil +} + +func (x *WrapperTesting) GetU64() *wrapperspb.UInt64Value { + if x != nil { + return x.xxx_hidden_U64 + } + return nil +} + +func (x *WrapperTesting) GetU32() *wrapperspb.UInt32Value { + if x != nil { + return x.xxx_hidden_U32 + } + return nil +} + +func (x *WrapperTesting) GetB() *wrapperspb.BoolValue { + if x != nil { + return x.xxx_hidden_B + } + return nil +} + +func (x *WrapperTesting) GetS() *wrapperspb.StringValue { + if x != nil { + return x.xxx_hidden_S + } + return nil +} + +func (x *WrapperTesting) GetBs() *wrapperspb.BytesValue { + if x != nil { + return x.xxx_hidden_Bs + } + return nil +} + +func (x *WrapperTesting) SetI32(v *wrapperspb.Int32Value) { + x.xxx_hidden_I32 = v +} + +func (x *WrapperTesting) SetD(v *wrapperspb.DoubleValue) { + x.xxx_hidden_D = v +} + +func (x *WrapperTesting) SetF(v *wrapperspb.FloatValue) { + x.xxx_hidden_F = v +} + +func (x *WrapperTesting) SetI64(v *wrapperspb.Int64Value) { + x.xxx_hidden_I64 = v +} + +func (x *WrapperTesting) SetU64(v *wrapperspb.UInt64Value) { + x.xxx_hidden_U64 = v +} + +func (x *WrapperTesting) SetU32(v *wrapperspb.UInt32Value) { + x.xxx_hidden_U32 = v +} + +func (x *WrapperTesting) SetB(v *wrapperspb.BoolValue) { + x.xxx_hidden_B = v +} + +func (x *WrapperTesting) SetS(v *wrapperspb.StringValue) { + x.xxx_hidden_S = v +} + +func (x *WrapperTesting) SetBs(v *wrapperspb.BytesValue) { + x.xxx_hidden_Bs = v +} + +func (x *WrapperTesting) HasI32() bool { + if x == nil { + return false + } + return x.xxx_hidden_I32 != nil +} + +func (x *WrapperTesting) HasD() bool { + if x == nil { + return false + } + return x.xxx_hidden_D != nil +} + +func (x *WrapperTesting) HasF() bool { + if x == nil { + return false + } + return x.xxx_hidden_F != nil +} + +func (x *WrapperTesting) HasI64() bool { + if x == nil { + return false + } + return x.xxx_hidden_I64 != nil +} + +func (x *WrapperTesting) HasU64() bool { + if x == nil { + return false + } + return x.xxx_hidden_U64 != nil +} + +func (x *WrapperTesting) HasU32() bool { + if x == nil { + return false + } + return x.xxx_hidden_U32 != nil +} + +func (x *WrapperTesting) HasB() bool { + if x == nil { + return false + } + return x.xxx_hidden_B != nil +} + +func (x *WrapperTesting) HasS() bool { + if x == nil { + return false + } + return x.xxx_hidden_S != nil +} + +func (x *WrapperTesting) HasBs() bool { + if x == nil { + return false + } + return x.xxx_hidden_Bs != nil +} + +func (x *WrapperTesting) ClearI32() { + x.xxx_hidden_I32 = nil +} + +func (x *WrapperTesting) ClearD() { + x.xxx_hidden_D = nil +} + +func (x *WrapperTesting) ClearF() { + x.xxx_hidden_F = nil +} + +func (x *WrapperTesting) ClearI64() { + x.xxx_hidden_I64 = nil +} + +func (x *WrapperTesting) ClearU64() { + x.xxx_hidden_U64 = nil +} + +func (x *WrapperTesting) ClearU32() { + x.xxx_hidden_U32 = nil +} + +func (x *WrapperTesting) ClearB() { + x.xxx_hidden_B = nil +} + +func (x *WrapperTesting) ClearS() { + x.xxx_hidden_S = nil +} + +func (x *WrapperTesting) ClearBs() { + x.xxx_hidden_Bs = nil +} + +type WrapperTesting_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + I32 *wrapperspb.Int32Value + D *wrapperspb.DoubleValue + F *wrapperspb.FloatValue + I64 *wrapperspb.Int64Value + U64 *wrapperspb.UInt64Value + U32 *wrapperspb.UInt32Value + B *wrapperspb.BoolValue + S *wrapperspb.StringValue + Bs *wrapperspb.BytesValue +} + +func (b0 WrapperTesting_builder) Build() *WrapperTesting { + m0 := &WrapperTesting{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_I32 = b.I32 + x.xxx_hidden_D = b.D + x.xxx_hidden_F = b.F + x.xxx_hidden_I64 = b.I64 + x.xxx_hidden_U64 = b.U64 + x.xxx_hidden_U32 = b.U32 + x.xxx_hidden_B = b.B + x.xxx_hidden_S = b.S + x.xxx_hidden_Bs = b.Bs + return m0 +} + var File_tests_example_v1_native_test_proto protoreflect.FileDescriptor const file_tests_example_v1_native_test_proto_rawDesc = "" + "\n" + - "\"tests/example/v1/native_test.proto\x12\x10tests.example.v1\x1a\x1bbuf/validate/validate.proto\x1a\x17gofakeit/gofakeit.proto\"\xb0\a\n" + + "\"tests/example/v1/native_test.proto\x12\x10tests.example.v1\x1a\x1bbuf/validate/validate.proto\x1a\x17gofakeit/gofakeit.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\xb0\a\n" + "\aBenchGT\x12-\n" + "\x02gt\x18\x01 \x01(\x05B\x1d\xbaH\x04\x1a\x02 \x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x02gt\x12/\n" + "\x03gte\x18\x02 \x01(\x05B\x1d\xbaH\x04\x1a\x02(\x00\xca\xe66\x12\x12\x10{intrange:1,100}R\x03gte\x12-\n" + @@ -829,7 +1098,24 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\bhostname\x18\x01 \x01(\tB\x19\xbaH\x04r\x02h\x01\xca\xe66\x0e\x12\f{domainname}R\bhostname\x12C\n" + "\rhost_and_port\x18\x02 \x01(\tB\x1f\xbaH\x05r\x03\x80\x02\x01\xca\xe66\x13\x12\x11{domainname}:8080R\vhostAndPort\x12*\n" + "\x05email\x18\x03 \x01(\tB\x14\xbaH\x04r\x02`\x01\xca\xe66\t\x12\a{email}R\x05email\x12(\n" + - "\x04uuid\x18\x04 \x01(\tB\x14\xbaH\x05r\x03\xb0\x01\x01\xca\xe66\b\x12\x06{uuid}R\x04uuid*a\n" + + "\x04uuid\x18\x04 \x01(\tB\x14\xbaH\x05r\x03\xb0\x01\x01\xca\xe66\b\x12\x06{uuid}R\x04uuid\"\x88\x04\n" + + "\x0eWrapperTesting\x126\n" + + "\x03i32\x18\x01 \x01(\v2\x1b.google.protobuf.Int32ValueB\a\xbaH\x04\x1a\x02 \n" + + "R\x03i32\x12:\n" + + "\x01d\x18\x02 \x01(\v2\x1c.google.protobuf.DoubleValueB\x0e\xbaH\v\x12\t!\x00\x00\x00\x00\x00\x00$@R\x01d\x125\n" + + "\x01f\x18\x03 \x01(\v2\x1b.google.protobuf.FloatValueB\n" + + "\xbaH\a\n" + + "\x05%\x00\x00 AR\x01f\x126\n" + + "\x03i64\x18\x04 \x01(\v2\x1b.google.protobuf.Int64ValueB\a\xbaH\x04\"\x02 \n" + + "R\x03i64\x127\n" + + "\x03u64\x18\x05 \x01(\v2\x1c.google.protobuf.UInt64ValueB\a\xbaH\x042\x02 \n" + + "R\x03u64\x127\n" + + "\x03u32\x18\x06 \x01(\v2\x1c.google.protobuf.UInt32ValueB\a\xbaH\x04*\x02 \n" + + "R\x03u32\x121\n" + + "\x01b\x18\a \x01(\v2\x1a.google.protobuf.BoolValueB\a\xbaH\x04j\x02\b\x01R\x01b\x128\n" + + "\x01s\x18\b \x01(\v2\x1c.google.protobuf.StringValueB\f\xbaH\tr\a\n" + + "\x05helloR\x01s\x124\n" + + "\x02bs\x18\t \x01(\v2\x1b.google.protobuf.BytesValueB\a\xbaH\x04z\x02h\x05R\x02bs*a\n" + "\bTestEnum\x12\x19\n" + "\x15TEST_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eTEST_ENUM_VAL1\x10\x01\x12\x12\n" + @@ -838,23 +1124,42 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\x14com.tests.example.v1B\x0fNativeTestProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" var file_tests_example_v1_native_test_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_tests_example_v1_native_test_proto_goTypes = []any{ - (TestEnum)(0), // 0: tests.example.v1.TestEnum - (*BenchGT)(nil), // 1: tests.example.v1.BenchGT - (*BenchTestBytes)(nil), // 2: tests.example.v1.BenchTestBytes - (*TestUnique)(nil), // 3: tests.example.v1.TestUnique - (*TestByteBroken)(nil), // 4: tests.example.v1.TestByteBroken - (*TestByteMatching)(nil), // 5: tests.example.v1.TestByteMatching - (*StringMatching)(nil), // 6: tests.example.v1.StringMatching + (TestEnum)(0), // 0: tests.example.v1.TestEnum + (*BenchGT)(nil), // 1: tests.example.v1.BenchGT + (*BenchTestBytes)(nil), // 2: tests.example.v1.BenchTestBytes + (*TestUnique)(nil), // 3: tests.example.v1.TestUnique + (*TestByteBroken)(nil), // 4: tests.example.v1.TestByteBroken + (*TestByteMatching)(nil), // 5: tests.example.v1.TestByteMatching + (*StringMatching)(nil), // 6: tests.example.v1.StringMatching + (*WrapperTesting)(nil), // 7: tests.example.v1.WrapperTesting + (*wrapperspb.Int32Value)(nil), // 8: google.protobuf.Int32Value + (*wrapperspb.DoubleValue)(nil), // 9: google.protobuf.DoubleValue + (*wrapperspb.FloatValue)(nil), // 10: google.protobuf.FloatValue + (*wrapperspb.Int64Value)(nil), // 11: google.protobuf.Int64Value + (*wrapperspb.UInt64Value)(nil), // 12: google.protobuf.UInt64Value + (*wrapperspb.UInt32Value)(nil), // 13: google.protobuf.UInt32Value + (*wrapperspb.BoolValue)(nil), // 14: google.protobuf.BoolValue + (*wrapperspb.StringValue)(nil), // 15: google.protobuf.StringValue + (*wrapperspb.BytesValue)(nil), // 16: google.protobuf.BytesValue } var file_tests_example_v1_native_test_proto_depIdxs = []int32{ - 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum + 8, // 1: tests.example.v1.WrapperTesting.i32:type_name -> google.protobuf.Int32Value + 9, // 2: tests.example.v1.WrapperTesting.d:type_name -> google.protobuf.DoubleValue + 10, // 3: tests.example.v1.WrapperTesting.f:type_name -> google.protobuf.FloatValue + 11, // 4: tests.example.v1.WrapperTesting.i64:type_name -> google.protobuf.Int64Value + 12, // 5: tests.example.v1.WrapperTesting.u64:type_name -> google.protobuf.UInt64Value + 13, // 6: tests.example.v1.WrapperTesting.u32:type_name -> google.protobuf.UInt32Value + 14, // 7: tests.example.v1.WrapperTesting.b:type_name -> google.protobuf.BoolValue + 15, // 8: tests.example.v1.WrapperTesting.s:type_name -> google.protobuf.StringValue + 16, // 9: tests.example.v1.WrapperTesting.bs:type_name -> google.protobuf.BytesValue + 10, // [10:10] is the sub-list for method output_type + 10, // [10:10] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_tests_example_v1_native_test_proto_init() } @@ -868,7 +1173,7 @@ func file_tests_example_v1_native_test_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_native_test_proto_rawDesc), len(file_tests_example_v1_native_test_proto_rawDesc)), NumEnums: 1, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto index 577c4f47..42c04296 100644 --- a/proto/tests/example/v1/native_test.proto +++ b/proto/tests/example/v1/native_test.proto @@ -18,6 +18,7 @@ package tests.example.v1; import "buf/validate/validate.proto"; import "gofakeit/gofakeit.proto"; +import "google/protobuf/wrappers.proto"; message BenchGT { int32 gt = 1 [ @@ -186,3 +187,33 @@ message StringMatching { (gofakeit.generate).tag = "{uuid}" ]; } + +message WrapperTesting { + google.protobuf.Int32Value i32 = 1 [ + (buf.validate.field).int32.gt = 10 + ]; + google.protobuf.DoubleValue d = 2 [ + (buf.validate.field).double.gt = 10 + ]; + google.protobuf.FloatValue f = 3 [ + (buf.validate.field).float.gt = 10 + ]; + google.protobuf.Int64Value i64 = 4 [ + (buf.validate.field).int64.gt = 10 + ]; + google.protobuf.UInt64Value u64 = 5 [ + (buf.validate.field).uint64.gt = 10 + ]; + google.protobuf.UInt32Value u32 = 6 [ + (buf.validate.field).uint32.gt = 10 + ]; + google.protobuf.BoolValue b = 7 [ + (buf.validate.field).bool.const = true + ]; + google.protobuf.StringValue s = 8 [ + (buf.validate.field).string.const = "hello" + ]; + google.protobuf.BytesValue bs = 9 [ + (buf.validate.field).bytes.len = 5 + ]; +} \ No newline at end of file diff --git a/validator_bench_test.go b/validator_bench_test.go index f0517724..2b49cdc2 100644 --- a/validator_bench_test.go +++ b/validator_bench_test.go @@ -24,6 +24,7 @@ import ( "github.com/rodaine/protogofakeit" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/wrapperspb" ) func BenchmarkScalar(b *testing.B) { @@ -67,6 +68,32 @@ func BenchmarkStringMatching(b *testing.B) { benchSuccess(b, &pb.StringMatching{}) } +func BenchmarkWrapperTesting(b *testing.B) { + msg := pb.WrapperTesting_builder{ + I32: &wrapperspb.Int32Value{Value: 11}, + D: &wrapperspb.DoubleValue{Value: 11}, + F: &wrapperspb.FloatValue{Value: 11}, + I64: &wrapperspb.Int64Value{Value: 11}, + U64: &wrapperspb.UInt64Value{Value: 11}, + U32: &wrapperspb.UInt32Value{Value: 11}, + B: &wrapperspb.BoolValue{Value: true}, + S: &wrapperspb.StringValue{Value: "hello"}, + Bs: &wrapperspb.BytesValue{Value: []byte("hello")}, + }.Build() + options := []ValidatorOption{WithMessages(msg), WithDisableLazy()} + if strings.EqualFold(os.Getenv("DISABLE_NATIVE_RULES"), "true") { + options = append(options, WithDisableNativeRules()) + } + val, err := New(options...) + require.NoError(b, err) + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = val.Validate(msg) + } +} + func TestInt32GT(t *testing.T) { t.Parallel() testSuccess(t, &pb.BenchGT{}) @@ -97,6 +124,25 @@ func TestStringMatching(t *testing.T) { testSuccess(t, &pb.StringMatching{}) } +func TestWrapperTesting(t *testing.T) { + t.Parallel() + msg := pb.WrapperTesting_builder{ + I32: &wrapperspb.Int32Value{Value: 11}, + D: &wrapperspb.DoubleValue{Value: 11}, + F: &wrapperspb.FloatValue{Value: 11}, + I64: &wrapperspb.Int64Value{Value: 11}, + U64: &wrapperspb.UInt64Value{Value: 11}, + U32: &wrapperspb.UInt32Value{Value: 11}, + B: &wrapperspb.BoolValue{Value: true}, + S: &wrapperspb.StringValue{Value: "hello"}, + Bs: &wrapperspb.BytesValue{Value: []byte("hello")}, + }.Build() + val, err := New(WithMessages(msg), WithDisableLazy()) + require.NoError(t, err) + err = val.Validate(msg) + require.NoError(t, err) +} + func TestRepeated(t *testing.T) { t.Parallel() t.Run("Scalar", func(t *testing.T) { diff --git a/wrapper.go b/wrapper.go new file mode 100644 index 00000000..f64db8c3 --- /dev/null +++ b/wrapper.go @@ -0,0 +1,42 @@ +// Copyright 2023-2026 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package protovalidate + +import "google.golang.org/protobuf/reflect/protoreflect" + +// wrappedValueEval adapts a native evaluator built against a wrapper WKT's +// inner scalar field (e.g. google.protobuf.Int32Value.value) so it can run +// when the outer value is the wrapper message itself. At evaluation time it +// extracts the inner scalar via Message().Get(innerField) and delegates. +// +// processWrapperRules calls buildValue with the inner "value" field +// descriptor, but appends the resulting evaluators onto the outer value +// whose Descriptor still points at the wrapper message field. Without this +// adapter, native evaluators that call val.Int()/Float()/etc. would see the +// wrapper message and panic. +type wrappedValueEval struct { + innerField protoreflect.FieldDescriptor + inner evaluator +} + +func (w wrappedValueEval) Evaluate(msg protoreflect.Message, val protoreflect.Value, cfg *validationConfig) error { + return w.inner.Evaluate(msg, val.Message().Get(w.innerField), cfg) +} + +func (w wrappedValueEval) Tautology() bool { + return w.inner.Tautology() +} + +var _ evaluator = wrappedValueEval{} From 2b87dc57894991f3cee19484cac0b1751921b3e3 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 23 Apr 2026 18:09:44 -0400 Subject: [PATCH 11/14] fix proto formatting --- proto/tests/example/v1/native_test.proto | 38 +++++++----------------- 1 file changed, 10 insertions(+), 28 deletions(-) diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto index 42c04296..69ef7dff 100644 --- a/proto/tests/example/v1/native_test.proto +++ b/proto/tests/example/v1/native_test.proto @@ -189,31 +189,13 @@ message StringMatching { } message WrapperTesting { - google.protobuf.Int32Value i32 = 1 [ - (buf.validate.field).int32.gt = 10 - ]; - google.protobuf.DoubleValue d = 2 [ - (buf.validate.field).double.gt = 10 - ]; - google.protobuf.FloatValue f = 3 [ - (buf.validate.field).float.gt = 10 - ]; - google.protobuf.Int64Value i64 = 4 [ - (buf.validate.field).int64.gt = 10 - ]; - google.protobuf.UInt64Value u64 = 5 [ - (buf.validate.field).uint64.gt = 10 - ]; - google.protobuf.UInt32Value u32 = 6 [ - (buf.validate.field).uint32.gt = 10 - ]; - google.protobuf.BoolValue b = 7 [ - (buf.validate.field).bool.const = true - ]; - google.protobuf.StringValue s = 8 [ - (buf.validate.field).string.const = "hello" - ]; - google.protobuf.BytesValue bs = 9 [ - (buf.validate.field).bytes.len = 5 - ]; -} \ No newline at end of file + google.protobuf.Int32Value i32 = 1 [(buf.validate.field).int32.gt = 10]; + google.protobuf.DoubleValue d = 2 [(buf.validate.field).double.gt = 10]; + google.protobuf.FloatValue f = 3 [(buf.validate.field).float.gt = 10]; + google.protobuf.Int64Value i64 = 4 [(buf.validate.field).int64.gt = 10]; + google.protobuf.UInt64Value u64 = 5 [(buf.validate.field).uint64.gt = 10]; + google.protobuf.UInt32Value u32 = 6 [(buf.validate.field).uint32.gt = 10]; + google.protobuf.BoolValue b = 7 [(buf.validate.field).bool.const = true]; + google.protobuf.StringValue s = 8 [(buf.validate.field).string.const = "hello"]; + google.protobuf.BytesValue bs = 9 [(buf.validate.field).bytes.len = 5]; +} From 8a8e5d6123fdf18109d5bcd7b4ae99711d0f1c43 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Thu, 23 Apr 2026 18:24:12 -0400 Subject: [PATCH 12/14] update README.md --- README.md | 93 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 620e0f4b..e4220e72 100644 --- a/README.md +++ b/README.md @@ -76,58 +76,63 @@ once with the native rules enabled, and once with the CEL rules enabled. Performance improvements on the included benchmarks: ``` -$ benchstat 2026-04-21:17:17:52.bench.txt 2026-04-21:17:20:31.bench.txt goos: darwin goarch: arm64 pkg: buf.build/go/protovalidate cpu: Apple M1 Max - │ 2026-04-21:17:17:52.bench.txt │ 2026-04-21:17:20:31.bench.txt │ - │ sec/op │ sec/op vs base │ -Scalar-10 167.35n ± 1% 70.97n ± 0% -57.59% (p=0.000 n=10) -Repeated/Scalar-10 285.60n ± 1% 99.10n ± 1% -65.30% (p=0.000 n=10) -Repeated/Message-10 691.3n ± 1% 296.7n ± 0% -57.09% (p=0.000 n=10) -Repeated/Unique/Scalar-10 1249.0n ± 0% 556.8n ± 0% -55.42% (p=0.000 n=10) -Repeated/Unique/Bytes-10 2514.5n ± 0% 967.0n ± 1% -61.54% (p=0.000 n=10) -Map-10 289.9n ± 1% 103.2n ± 1% -64.39% (p=0.000 n=10) -ComplexSchema-10 40.93µ ± 0% 14.27µ ± 1% -65.14% (p=0.000 n=10) -Int32GT-10 2748.0n ± 0% 845.6n ± 0% -69.23% (p=0.000 n=10) -TestByteMatching-10 1348.0n ± 0% 206.2n ± 0% -84.70% (p=0.000 n=10) -Compile-10 8.262m ± 0% 1.357m ± 1% -83.58% (p=0.000 n=10) -CompileInt32GT-10 5.887m ± 1% 1.241m ± 0% -78.91% (p=0.000 n=10) -geomean 5.738µ 1.755µ -69.42% - - │ 2026-04-21:17:17:52.bench.txt │ 2026-04-21:17:20:31.bench.txt │ - │ B/op │ B/op vs base │ -Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -Repeated/Scalar-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) -Repeated/Message-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) -Repeated/Unique/Scalar-10 536.0 ± 0% 272.0 ± 0% -49.25% (p=0.000 n=10) -Repeated/Unique/Bytes-10 1784.0 ± 0% 832.0 ± 0% -53.36% (p=0.000 n=10) -Map-10 128.00 ± 0% 64.00 ± 0% -50.00% (p=0.000 n=10) -ComplexSchema-10 10.552Ki ± 0% 4.383Ki ± 0% -58.46% (p=0.000 n=10) -Int32GT-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -TestByteMatching-10 408.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) -Compile-10 6.811Mi ± 0% 1.671Mi ± 0% -75.47% (p=0.000 n=10) -CompileInt32GT-10 5.199Mi ± 0% 1.606Mi ± 0% -69.11% (p=0.000 n=10) -geomean ² ? ² ³ + │ .tmp/bench/2026-04-23:18:10:09.bench.txt │ .tmp/bench/2026-04-23:18:13:13.bench.txt │ + │ sec/op │ sec/op vs base │ +Scalar-10 174.30n ± 1% 78.03n ± 1% -55.24% (p=0.000 n=10) +Repeated/Scalar-10 291.70n ± 1% 98.94n ± 0% -66.08% (p=0.000 n=10) +Repeated/Message-10 711.0n ± 1% 323.9n ± 0% -54.44% (p=0.000 n=10) +Repeated/Unique/Scalar-10 1275.5n ± 0% 322.2n ± 0% -74.74% (p=0.000 n=10) +Repeated/Unique/Bytes-10 2560.5n ± 0% 462.2n ± 0% -81.95% (p=0.000 n=10) +Map-10 298.0n ± 1% 104.3n ± 0% -64.99% (p=0.000 n=10) +ComplexSchema-10 41.19µ ± 1% 14.89µ ± 0% -63.84% (p=0.000 n=10) +Int32GT-10 2.807µ ± 1% 1.015µ ± 0% -63.86% (p=0.000 n=10) +TestByteMatching-10 1368.0n ± 0% 213.5n ± 1% -84.39% (p=0.000 n=10) +StringMatching-10 1937.0n ± 1% 936.3n ± 0% -51.66% (p=0.000 n=10) +WrapperTesting-10 3.133µ ± 0% 1.293µ ± 0% -58.73% (p=0.000 n=10) +Compile-10 8.315m ± 2% 1.371m ± 1% -83.52% (p=0.000 n=10) +CompileInt32GT-10 5.925m ± 1% 1.296m ± 3% -78.13% (p=0.000 n=10) +geomean 5.120µ 1.538µ -69.96% + + │ .tmp/bench/2026-04-23:18:10:09.bench.txt │ .tmp/bench/2026-04-23:18:13:13.bench.txt │ + │ B/op │ B/op vs base │ +Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ +Repeated/Scalar-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) +Repeated/Message-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) +Repeated/Unique/Scalar-10 536.0 ± 0% 132.0 ± 0% -75.37% (p=0.000 n=10) +Repeated/Unique/Bytes-10 1784.0 ± 0% 264.0 ± 0% -85.20% (p=0.000 n=10) +Map-10 128.00 ± 0% 64.00 ± 0% -50.00% (p=0.000 n=10) +ComplexSchema-10 10.552Ki ± 0% 4.250Ki ± 0% -59.72% (p=0.000 n=10) +Int32GT-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ +TestByteMatching-10 408.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) +StringMatching-10 387.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) +WrapperTesting-10 192.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) +Compile-10 6.816Mi ± 0% 1.675Mi ± 0% -75.42% (p=0.000 n=10) +CompileInt32GT-10 5.204Mi ± 0% 1.611Mi ± 0% -69.05% (p=0.000 n=10) +geomean ² ? ² ³ ¹ all samples are equal ² summaries must be >0 to compute geomean ³ ratios must be >0 to compute geomean - │ 2026-04-21:17:17:52.bench.txt │ 2026-04-21:17:20:31.bench.txt │ - │ allocs/op │ allocs/op vs base │ -Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -Repeated/Scalar-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) -Repeated/Message-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) -Repeated/Unique/Scalar-10 34.00 ± 0% 14.00 ± 0% -58.82% (p=0.000 n=10) -Repeated/Unique/Bytes-10 73.00 ± 0% 24.00 ± 0% -67.12% (p=0.000 n=10) -Map-10 2.000 ± 0% 1.000 ± 0% -50.00% (p=0.000 n=10) -ComplexSchema-10 419.0 ± 0% 131.0 ± 0% -68.74% (p=0.000 n=10) -Int32GT-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -TestByteMatching-10 17.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) -Compile-10 96.14k ± 0% 18.35k ± 0% -80.91% (p=0.000 n=10) -CompileInt32GT-10 64.56k ± 0% 17.53k ± 0% -72.84% (p=0.000 n=10) -geomean ² ? ² ³ + │ .tmp/bench/2026-04-23:18:10:09.bench.txt │ .tmp/bench/2026-04-23:18:13:13.bench.txt │ + │ allocs/op │ allocs/op vs base │ +Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ +Repeated/Scalar-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) +Repeated/Message-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) +Repeated/Unique/Scalar-10 34.00 ± 0% 11.00 ± 0% -67.65% (p=0.000 n=10) +Repeated/Unique/Bytes-10 73.00 ± 0% 10.00 ± 0% -86.30% (p=0.000 n=10) +Map-10 2.000 ± 0% 1.000 ± 0% -50.00% (p=0.000 n=10) +ComplexSchema-10 419.0 ± 0% 121.0 ± 0% -71.12% (p=0.000 n=10) +Int32GT-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ +TestByteMatching-10 17.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) +StringMatching-10 23.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) +WrapperTesting-10 17.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) +Compile-10 96.21k ± 0% 18.41k ± 0% -80.86% (p=0.000 n=10) +CompileInt32GT-10 64.63k ± 0% 17.60k ± 0% -72.77% (p=0.000 n=10) +geomean ² ? ² ³ ¹ all samples are equal ² summaries must be >0 to compute geomean ³ ratios must be >0 to compute geomean From be9b9af21bf95dae166bcbec0d08fae63387771c Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Fri, 24 Apr 2026 18:06:06 -0400 Subject: [PATCH 13/14] add benchmark and test for error cases. add some performance optimizations for error generation (still room for more improvement). add fall-through support for any rules that aren't covered in native. --- README.md | 50 ++-- base.go | 80 ++++++- builder.go | 10 +- .../gen/tests/example/v1/native_test.pb.go | 103 +++++++-- .../example/v1/native_test_protoopaque.pb.go | 103 +++++++-- native_bool.go | 13 +- native_bytes.go | 115 ++++++---- native_enum.go | 15 +- native_map.go | 18 +- native_numeric.go | 153 ++++++------- native_numeric_test.go | 51 +++-- native_repeated.go | 28 ++- native_string.go | 215 ++++++++---------- proto/tests/example/v1/native_test.proto | 7 + validator_bench_test.go | 30 +++ 15 files changed, 637 insertions(+), 354 deletions(-) diff --git a/README.md b/README.md index e4220e72..576b161d 100644 --- a/README.md +++ b/README.md @@ -80,24 +80,26 @@ goos: darwin goarch: arm64 pkg: buf.build/go/protovalidate cpu: Apple M1 Max - │ .tmp/bench/2026-04-23:18:10:09.bench.txt │ .tmp/bench/2026-04-23:18:13:13.bench.txt │ + │ .tmp/bench/2026-04-24:17:56:31.bench.txt │ .tmp/bench/2026-04-24:18:00:25.bench.txt │ │ sec/op │ sec/op vs base │ -Scalar-10 174.30n ± 1% 78.03n ± 1% -55.24% (p=0.000 n=10) -Repeated/Scalar-10 291.70n ± 1% 98.94n ± 0% -66.08% (p=0.000 n=10) -Repeated/Message-10 711.0n ± 1% 323.9n ± 0% -54.44% (p=0.000 n=10) -Repeated/Unique/Scalar-10 1275.5n ± 0% 322.2n ± 0% -74.74% (p=0.000 n=10) -Repeated/Unique/Bytes-10 2560.5n ± 0% 462.2n ± 0% -81.95% (p=0.000 n=10) -Map-10 298.0n ± 1% 104.3n ± 0% -64.99% (p=0.000 n=10) -ComplexSchema-10 41.19µ ± 1% 14.89µ ± 0% -63.84% (p=0.000 n=10) -Int32GT-10 2.807µ ± 1% 1.015µ ± 0% -63.86% (p=0.000 n=10) -TestByteMatching-10 1368.0n ± 0% 213.5n ± 1% -84.39% (p=0.000 n=10) -StringMatching-10 1937.0n ± 1% 936.3n ± 0% -51.66% (p=0.000 n=10) -WrapperTesting-10 3.133µ ± 0% 1.293µ ± 0% -58.73% (p=0.000 n=10) -Compile-10 8.315m ± 2% 1.371m ± 1% -83.52% (p=0.000 n=10) -CompileInt32GT-10 5.925m ± 1% 1.296m ± 3% -78.13% (p=0.000 n=10) -geomean 5.120µ 1.538µ -69.96% - - │ .tmp/bench/2026-04-23:18:10:09.bench.txt │ .tmp/bench/2026-04-23:18:13:13.bench.txt │ +Scalar-10 165.90n ± 1% 68.80n ± 1% -58.53% (p=0.000 n=10) +Repeated/Scalar-10 279.80n ± 0% 95.84n ± 1% -65.75% (p=0.000 n=10) +Repeated/Message-10 685.5n ± 1% 288.8n ± 1% -57.86% (p=0.000 n=10) +Repeated/Unique/Scalar-10 1248.0n ± 1% 311.3n ± 0% -75.05% (p=0.000 n=10) +Repeated/Unique/Bytes-10 2491.0n ± 2% 448.3n ± 1% -82.00% (p=0.000 n=10) +Map-10 284.2n ± 1% 100.8n ± 0% -64.52% (p=0.000 n=10) +ComplexSchema-10 40.50µ ± 1% 13.47µ ± 0% -66.74% (p=0.000 n=10) +Int32GT-10 2744.5n ± 0% 774.6n ± 0% -71.78% (p=0.000 n=10) +TestByteMatching-10 1334.0n ± 1% 208.2n ± 1% -84.39% (p=0.000 n=10) +StringMatching-10 1893.0n ± 1% 906.5n ± 1% -52.11% (p=0.000 n=10) +WrapperTesting-10 3.044µ ± 1% 1.140µ ± 1% -62.57% (p=0.000 n=10) +Compile-10 8.137m ± 1% 1.983m ± 1% -75.63% (p=0.000 n=10) +CompileInt32GT-10 5.775m ± 1% 1.639m ± 2% -71.61% (p=0.000 n=10) +MultiRuleError-10 783.4n ± 1% 686.8n ± 2% -12.33% (p=0.000 n=10) +MultiRuleNoError-10 234.80n ± 1% 69.11n ± 0% -70.57% (p=0.000 n=10) +geomean 3.585µ 1.160µ -67.64% + + │ .tmp/bench/2026-04-24:17:56:31.bench.txt │ .tmp/bench/2026-04-24:18:00:25.bench.txt │ │ B/op │ B/op vs base │ Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ Repeated/Scalar-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) @@ -110,14 +112,16 @@ Int32GT-10 0.000 ± 0% 0.000 TestByteMatching-10 408.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) StringMatching-10 387.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) WrapperTesting-10 192.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) -Compile-10 6.816Mi ± 0% 1.675Mi ± 0% -75.42% (p=0.000 n=10) -CompileInt32GT-10 5.204Mi ± 0% 1.611Mi ± 0% -69.05% (p=0.000 n=10) +Compile-10 6.826Mi ± 0% 2.039Mi ± 0% -70.13% (p=0.000 n=10) +CompileInt32GT-10 5.210Mi ± 0% 1.842Mi ± 0% -64.65% (p=0.000 n=10) +MultiRuleError-10 784.0 ± 0% 808.0 ± 0% +3.06% (p=0.000 n=10) +MultiRuleNoError-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ geomean ² ? ² ³ ¹ all samples are equal ² summaries must be >0 to compute geomean ³ ratios must be >0 to compute geomean - │ .tmp/bench/2026-04-23:18:10:09.bench.txt │ .tmp/bench/2026-04-23:18:13:13.bench.txt │ + │ .tmp/bench/2026-04-24:17:56:31.bench.txt │ .tmp/bench/2026-04-24:18:00:25.bench.txt │ │ allocs/op │ allocs/op vs base │ Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ Repeated/Scalar-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) @@ -130,8 +134,10 @@ Int32GT-10 0.000 ± 0% 0.000 TestByteMatching-10 17.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) StringMatching-10 23.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) WrapperTesting-10 17.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) -Compile-10 96.21k ± 0% 18.41k ± 0% -80.86% (p=0.000 n=10) -CompileInt32GT-10 64.63k ± 0% 17.60k ± 0% -72.77% (p=0.000 n=10) +Compile-10 96.37k ± 0% 21.58k ± 0% -77.61% (p=0.000 n=10) +CompileInt32GT-10 64.73k ± 0% 19.62k ± 0% -69.69% (p=0.000 n=10) +MultiRuleError-10 21.00 ± 0% 22.00 ± 0% +4.76% (p=0.000 n=10) +MultiRuleNoError-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ geomean ² ? ² ³ ¹ all samples are equal ² summaries must be >0 to compute geomean diff --git a/base.go b/base.go index 2a79c4b7..bcead0e3 100644 --- a/base.go +++ b/base.go @@ -61,33 +61,95 @@ func (b *base) rulePath(suffix *validate.FieldPath) *validate.FieldPath { return prefixRulePath(b.RulePrefix, suffix) } +// ruleSite is a compile-time pre-built bundle for a single rule site: the +// 2-element rule path suffix and the leaf descriptor. Using ruleSite with +// newViolationAt avoids re-allocating FieldPathElement proto messages on +// every violation (each FieldPathElement rebuild allocates ~4 sub-objects). +type ruleSite struct { + // pathElements is the rule path suffix, e.g. + // [FieldRules.int32 element, Int32Rules.gt element]. + // + // Safe to share across violations: only the containing *FieldPath is + // rebuilt per violation, which is what updateViolationPaths mutates. + pathElements []*validate.FieldPathElement + // desc is the leaf rule field descriptor (e.g. Int32Rules.gt); it is + // stored on the returned *Violation's RuleDescriptor field. + desc protoreflect.FieldDescriptor + + // if there are constant values for ruleID or message, specify them once so they can be reused + ruleID *string + message *string +} + +// makeRuleSite pre-builds a ruleSite for a rule at compile time. +func makeRuleSite(ruleDesc, desc protoreflect.FieldDescriptor) ruleSite { + return ruleSite{ + pathElements: []*validate.FieldPathElement{ + fieldPathElement(ruleDesc), + fieldPathElement(desc), + }, + desc: desc, + } +} + +// makeRuleSiteWithID pre-builds a ruleSite for a rule at compile time. +func makeRuleSiteWithID(ruleDesc, desc protoreflect.FieldDescriptor, ruleID string) ruleSite { + return ruleSite{ + pathElements: []*validate.FieldPathElement{ + fieldPathElement(ruleDesc), + fieldPathElement(desc), + }, + desc: desc, + ruleID: proto.String(ruleID), + } +} + +// makeRuleSiteWithID pre-builds a ruleSite for a rule at compile time. +func makeRuleSiteWithIDAndMessage(ruleDesc, desc protoreflect.FieldDescriptor, ruleID string, message string) ruleSite { + return ruleSite{ + pathElements: []*validate.FieldPathElement{ + fieldPathElement(ruleDesc), + fieldPathElement(desc), + }, + desc: desc, + ruleID: proto.String(ruleID), + message: proto.String(message), + } +} + // newViolation constructs a Violation. // ruleDesc is the top-level rule descriptor (e.g., FieldRules.int32), // desc is the specific constraint descriptor (e.g., Int32Rules.gt). +// +// it uses pre-built path elements in site instead of rebuilding them each call. func (b *base) newViolation( - ruleDesc protoreflect.FieldDescriptor, - desc protoreflect.FieldDescriptor, + site ruleSite, ruleID string, message string, fieldValue protoreflect.Value, ruleValue protoreflect.Value, ) *Violation { + ruleIDPtr := site.ruleID + if ruleIDPtr == nil { + ruleIDPtr = proto.String(ruleID) + } + messagePtr := site.message + if messagePtr == nil { + messagePtr = proto.String(message) + } return &Violation{ Proto: validate.Violation_builder{ Field: b.fieldPath(), Rule: b.rulePath(validate.FieldPath_builder{ - Elements: []*validate.FieldPathElement{ - fieldPathElement(ruleDesc), - fieldPathElement(desc), - }, + Elements: site.pathElements, }.Build()), - RuleId: proto.String(ruleID), - Message: proto.String(message), + RuleId: ruleIDPtr, + Message: messagePtr, }.Build(), FieldValue: fieldValue, FieldDescriptor: b.Descriptor, RuleValue: ruleValue, - RuleDescriptor: desc, + RuleDescriptor: site.desc, } } diff --git a/builder.go b/builder.go index d4bdea39..48330cf8 100644 --- a/builder.go +++ b/builder.go @@ -467,6 +467,13 @@ func (bldr *builder) processStandardRules( } } + // make a copy of the rules because we're going to clear anything handled by native rules, leaving + // anything else to be handled by CEL rules. this allows us to fall back to CEL rules if there is no + // native rule (for example, when a new rule is added to the validate proto, but the native code hasn't + // been updated. we are making a copy because we don't want to modify the original rules in case they are + // reused (happens in some test cases, could happen in production code with dynamic messages). + rules = proto.CloneOf[*validate.FieldRules](rules) + // put behind a feature flag to allow for testing. // it's easier to follow like this, don't break it up //nolint:nestif @@ -475,21 +482,18 @@ func (bldr *builder) processStandardRules( if fdesc.IsList() && valEval.NestedRule == nil { if native := tryNativeRepeatedRules(newBase(valEval), rules.GetRepeated()); native != nil { valEval.Append(native) - return nil } } // Try native Go evaluators for map-level rules (min_pairs, max_pairs). if fdesc.IsMap() && valEval.NestedRule == nil { if native := tryNativeMapRules(newBase(valEval), rules.GetMap()); native != nil { valEval.Append(native) - return nil } } // Try native Go evaluators for known simple rules before falling back to CEL. if !fdesc.IsMap() && !fdesc.IsList() { if native := bldr.tryNativeRules(fdesc, rules, valEval); native != nil { valEval.Append(native) - return nil } } } diff --git a/internal/gen/tests/example/v1/native_test.pb.go b/internal/gen/tests/example/v1/native_test.pb.go index 366d1a38..54a0d2d5 100644 --- a/internal/gen/tests/example/v1/native_test.pb.go +++ b/internal/gen/tests/example/v1/native_test.pb.go @@ -1053,6 +1053,63 @@ func (b0 WrapperTesting_builder) Build() *WrapperTesting { return m0 } +type MultiRule struct { + state protoimpl.MessageState `protogen:"hybrid.v1"` + Many int64 `protobuf:"varint,1,opt,name=many,proto3" json:"many,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MultiRule) Reset() { + *x = MultiRule{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MultiRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MultiRule) ProtoMessage() {} + +func (x *MultiRule) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *MultiRule) GetMany() int64 { + if x != nil { + return x.Many + } + return 0 +} + +func (x *MultiRule) SetMany(v int64) { + x.Many = v +} + +type MultiRule_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Many int64 +} + +func (b0 MultiRule_builder) Build() *MultiRule { + m0 := &MultiRule{} + b, x := &b0, m0 + _, _ = b, x + x.Many = b.Many + return m0 +} + var File_tests_example_v1_native_test_proto protoreflect.FileDescriptor const file_tests_example_v1_native_test_proto_rawDesc = "" + @@ -1115,7 +1172,10 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\x01b\x18\a \x01(\v2\x1a.google.protobuf.BoolValueB\a\xbaH\x04j\x02\b\x01R\x01b\x128\n" + "\x01s\x18\b \x01(\v2\x1c.google.protobuf.StringValueB\f\xbaH\tr\a\n" + "\x05helloR\x01s\x124\n" + - "\x02bs\x18\t \x01(\v2\x1b.google.protobuf.BytesValueB\a\xbaH\x04z\x02h\x05R\x02bs*a\n" + + "\x02bs\x18\t \x01(\v2\x1b.google.protobuf.BytesValueB\a\xbaH\x04z\x02h\x05R\x02bs\"*\n" + + "\tMultiRule\x12\x1d\n" + + "\x04many\x18\x01 \x01(\x03B\t\xbaH\x06\"\x04\b\n" + + " \x05R\x04many*a\n" + "\bTestEnum\x12\x19\n" + "\x15TEST_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eTEST_ENUM_VAL1\x10\x01\x12\x12\n" + @@ -1124,7 +1184,7 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\x14com.tests.example.v1B\x0fNativeTestProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" var file_tests_example_v1_native_test_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_tests_example_v1_native_test_proto_goTypes = []any{ (TestEnum)(0), // 0: tests.example.v1.TestEnum (*BenchGT)(nil), // 1: tests.example.v1.BenchGT @@ -1134,27 +1194,28 @@ var file_tests_example_v1_native_test_proto_goTypes = []any{ (*TestByteMatching)(nil), // 5: tests.example.v1.TestByteMatching (*StringMatching)(nil), // 6: tests.example.v1.StringMatching (*WrapperTesting)(nil), // 7: tests.example.v1.WrapperTesting - (*wrapperspb.Int32Value)(nil), // 8: google.protobuf.Int32Value - (*wrapperspb.DoubleValue)(nil), // 9: google.protobuf.DoubleValue - (*wrapperspb.FloatValue)(nil), // 10: google.protobuf.FloatValue - (*wrapperspb.Int64Value)(nil), // 11: google.protobuf.Int64Value - (*wrapperspb.UInt64Value)(nil), // 12: google.protobuf.UInt64Value - (*wrapperspb.UInt32Value)(nil), // 13: google.protobuf.UInt32Value - (*wrapperspb.BoolValue)(nil), // 14: google.protobuf.BoolValue - (*wrapperspb.StringValue)(nil), // 15: google.protobuf.StringValue - (*wrapperspb.BytesValue)(nil), // 16: google.protobuf.BytesValue + (*MultiRule)(nil), // 8: tests.example.v1.MultiRule + (*wrapperspb.Int32Value)(nil), // 9: google.protobuf.Int32Value + (*wrapperspb.DoubleValue)(nil), // 10: google.protobuf.DoubleValue + (*wrapperspb.FloatValue)(nil), // 11: google.protobuf.FloatValue + (*wrapperspb.Int64Value)(nil), // 12: google.protobuf.Int64Value + (*wrapperspb.UInt64Value)(nil), // 13: google.protobuf.UInt64Value + (*wrapperspb.UInt32Value)(nil), // 14: google.protobuf.UInt32Value + (*wrapperspb.BoolValue)(nil), // 15: google.protobuf.BoolValue + (*wrapperspb.StringValue)(nil), // 16: google.protobuf.StringValue + (*wrapperspb.BytesValue)(nil), // 17: google.protobuf.BytesValue } var file_tests_example_v1_native_test_proto_depIdxs = []int32{ 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum - 8, // 1: tests.example.v1.WrapperTesting.i32:type_name -> google.protobuf.Int32Value - 9, // 2: tests.example.v1.WrapperTesting.d:type_name -> google.protobuf.DoubleValue - 10, // 3: tests.example.v1.WrapperTesting.f:type_name -> google.protobuf.FloatValue - 11, // 4: tests.example.v1.WrapperTesting.i64:type_name -> google.protobuf.Int64Value - 12, // 5: tests.example.v1.WrapperTesting.u64:type_name -> google.protobuf.UInt64Value - 13, // 6: tests.example.v1.WrapperTesting.u32:type_name -> google.protobuf.UInt32Value - 14, // 7: tests.example.v1.WrapperTesting.b:type_name -> google.protobuf.BoolValue - 15, // 8: tests.example.v1.WrapperTesting.s:type_name -> google.protobuf.StringValue - 16, // 9: tests.example.v1.WrapperTesting.bs:type_name -> google.protobuf.BytesValue + 9, // 1: tests.example.v1.WrapperTesting.i32:type_name -> google.protobuf.Int32Value + 10, // 2: tests.example.v1.WrapperTesting.d:type_name -> google.protobuf.DoubleValue + 11, // 3: tests.example.v1.WrapperTesting.f:type_name -> google.protobuf.FloatValue + 12, // 4: tests.example.v1.WrapperTesting.i64:type_name -> google.protobuf.Int64Value + 13, // 5: tests.example.v1.WrapperTesting.u64:type_name -> google.protobuf.UInt64Value + 14, // 6: tests.example.v1.WrapperTesting.u32:type_name -> google.protobuf.UInt32Value + 15, // 7: tests.example.v1.WrapperTesting.b:type_name -> google.protobuf.BoolValue + 16, // 8: tests.example.v1.WrapperTesting.s:type_name -> google.protobuf.StringValue + 17, // 9: tests.example.v1.WrapperTesting.bs:type_name -> google.protobuf.BytesValue 10, // [10:10] is the sub-list for method output_type 10, // [10:10] is the sub-list for method input_type 10, // [10:10] is the sub-list for extension type_name @@ -1173,7 +1234,7 @@ func file_tests_example_v1_native_test_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_native_test_proto_rawDesc), len(file_tests_example_v1_native_test_proto_rawDesc)), NumEnums: 1, - NumMessages: 7, + NumMessages: 8, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go index 4ab02c7d..12d59bdc 100644 --- a/internal/gen/tests/example/v1/native_test_protoopaque.pb.go +++ b/internal/gen/tests/example/v1/native_test_protoopaque.pb.go @@ -1053,6 +1053,63 @@ func (b0 WrapperTesting_builder) Build() *WrapperTesting { return m0 } +type MultiRule struct { + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Many int64 `protobuf:"varint,1,opt,name=many,proto3"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MultiRule) Reset() { + *x = MultiRule{} + mi := &file_tests_example_v1_native_test_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MultiRule) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MultiRule) ProtoMessage() {} + +func (x *MultiRule) ProtoReflect() protoreflect.Message { + mi := &file_tests_example_v1_native_test_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (x *MultiRule) GetMany() int64 { + if x != nil { + return x.xxx_hidden_Many + } + return 0 +} + +func (x *MultiRule) SetMany(v int64) { + x.xxx_hidden_Many = v +} + +type MultiRule_builder struct { + _ [0]func() // Prevents comparability and use of unkeyed literals for the builder. + + Many int64 +} + +func (b0 MultiRule_builder) Build() *MultiRule { + m0 := &MultiRule{} + b, x := &b0, m0 + _, _ = b, x + x.xxx_hidden_Many = b.Many + return m0 +} + var File_tests_example_v1_native_test_proto protoreflect.FileDescriptor const file_tests_example_v1_native_test_proto_rawDesc = "" + @@ -1115,7 +1172,10 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\x01b\x18\a \x01(\v2\x1a.google.protobuf.BoolValueB\a\xbaH\x04j\x02\b\x01R\x01b\x128\n" + "\x01s\x18\b \x01(\v2\x1c.google.protobuf.StringValueB\f\xbaH\tr\a\n" + "\x05helloR\x01s\x124\n" + - "\x02bs\x18\t \x01(\v2\x1b.google.protobuf.BytesValueB\a\xbaH\x04z\x02h\x05R\x02bs*a\n" + + "\x02bs\x18\t \x01(\v2\x1b.google.protobuf.BytesValueB\a\xbaH\x04z\x02h\x05R\x02bs\"*\n" + + "\tMultiRule\x12\x1d\n" + + "\x04many\x18\x01 \x01(\x03B\t\xbaH\x06\"\x04\b\n" + + " \x05R\x04many*a\n" + "\bTestEnum\x12\x19\n" + "\x15TEST_ENUM_UNSPECIFIED\x10\x00\x12\x12\n" + "\x0eTEST_ENUM_VAL1\x10\x01\x12\x12\n" + @@ -1124,7 +1184,7 @@ const file_tests_example_v1_native_test_proto_rawDesc = "" + "\x14com.tests.example.v1B\x0fNativeTestProtoP\x01ZBbuf.build/go/protovalidate/internal/gen/tests/example/v1;examplev1\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3" var file_tests_example_v1_native_test_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_tests_example_v1_native_test_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_tests_example_v1_native_test_proto_goTypes = []any{ (TestEnum)(0), // 0: tests.example.v1.TestEnum (*BenchGT)(nil), // 1: tests.example.v1.BenchGT @@ -1134,27 +1194,28 @@ var file_tests_example_v1_native_test_proto_goTypes = []any{ (*TestByteMatching)(nil), // 5: tests.example.v1.TestByteMatching (*StringMatching)(nil), // 6: tests.example.v1.StringMatching (*WrapperTesting)(nil), // 7: tests.example.v1.WrapperTesting - (*wrapperspb.Int32Value)(nil), // 8: google.protobuf.Int32Value - (*wrapperspb.DoubleValue)(nil), // 9: google.protobuf.DoubleValue - (*wrapperspb.FloatValue)(nil), // 10: google.protobuf.FloatValue - (*wrapperspb.Int64Value)(nil), // 11: google.protobuf.Int64Value - (*wrapperspb.UInt64Value)(nil), // 12: google.protobuf.UInt64Value - (*wrapperspb.UInt32Value)(nil), // 13: google.protobuf.UInt32Value - (*wrapperspb.BoolValue)(nil), // 14: google.protobuf.BoolValue - (*wrapperspb.StringValue)(nil), // 15: google.protobuf.StringValue - (*wrapperspb.BytesValue)(nil), // 16: google.protobuf.BytesValue + (*MultiRule)(nil), // 8: tests.example.v1.MultiRule + (*wrapperspb.Int32Value)(nil), // 9: google.protobuf.Int32Value + (*wrapperspb.DoubleValue)(nil), // 10: google.protobuf.DoubleValue + (*wrapperspb.FloatValue)(nil), // 11: google.protobuf.FloatValue + (*wrapperspb.Int64Value)(nil), // 12: google.protobuf.Int64Value + (*wrapperspb.UInt64Value)(nil), // 13: google.protobuf.UInt64Value + (*wrapperspb.UInt32Value)(nil), // 14: google.protobuf.UInt32Value + (*wrapperspb.BoolValue)(nil), // 15: google.protobuf.BoolValue + (*wrapperspb.StringValue)(nil), // 16: google.protobuf.StringValue + (*wrapperspb.BytesValue)(nil), // 17: google.protobuf.BytesValue } var file_tests_example_v1_native_test_proto_depIdxs = []int32{ 0, // 0: tests.example.v1.TestUnique.enums:type_name -> tests.example.v1.TestEnum - 8, // 1: tests.example.v1.WrapperTesting.i32:type_name -> google.protobuf.Int32Value - 9, // 2: tests.example.v1.WrapperTesting.d:type_name -> google.protobuf.DoubleValue - 10, // 3: tests.example.v1.WrapperTesting.f:type_name -> google.protobuf.FloatValue - 11, // 4: tests.example.v1.WrapperTesting.i64:type_name -> google.protobuf.Int64Value - 12, // 5: tests.example.v1.WrapperTesting.u64:type_name -> google.protobuf.UInt64Value - 13, // 6: tests.example.v1.WrapperTesting.u32:type_name -> google.protobuf.UInt32Value - 14, // 7: tests.example.v1.WrapperTesting.b:type_name -> google.protobuf.BoolValue - 15, // 8: tests.example.v1.WrapperTesting.s:type_name -> google.protobuf.StringValue - 16, // 9: tests.example.v1.WrapperTesting.bs:type_name -> google.protobuf.BytesValue + 9, // 1: tests.example.v1.WrapperTesting.i32:type_name -> google.protobuf.Int32Value + 10, // 2: tests.example.v1.WrapperTesting.d:type_name -> google.protobuf.DoubleValue + 11, // 3: tests.example.v1.WrapperTesting.f:type_name -> google.protobuf.FloatValue + 12, // 4: tests.example.v1.WrapperTesting.i64:type_name -> google.protobuf.Int64Value + 13, // 5: tests.example.v1.WrapperTesting.u64:type_name -> google.protobuf.UInt64Value + 14, // 6: tests.example.v1.WrapperTesting.u32:type_name -> google.protobuf.UInt32Value + 15, // 7: tests.example.v1.WrapperTesting.b:type_name -> google.protobuf.BoolValue + 16, // 8: tests.example.v1.WrapperTesting.s:type_name -> google.protobuf.StringValue + 17, // 9: tests.example.v1.WrapperTesting.bs:type_name -> google.protobuf.BytesValue 10, // [10:10] is the sub-list for method output_type 10, // [10:10] is the sub-list for method input_type 10, // [10:10] is the sub-list for extension type_name @@ -1173,7 +1234,7 @@ func file_tests_example_v1_native_test_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tests_example_v1_native_test_proto_rawDesc), len(file_tests_example_v1_native_test_proto_rawDesc)), NumEnums: 1, - NumMessages: 7, + NumMessages: 8, NumExtensions: 0, NumServices: 0, }, diff --git a/native_bool.go b/native_bool.go index dce37347..8db75039 100644 --- a/native_bool.go +++ b/native_bool.go @@ -23,8 +23,11 @@ import ( //nolint:gochecknoglobals var ( - boolRuleDesc = fieldRulesDesc.Fields().ByName("bool") - boolConstDesc = (*validate.BoolRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const") + boolConstSite = makeRuleSiteWithID( + fieldRulesDesc.Fields().ByName("bool"), + (*validate.BoolRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const"), + "bool.const", + ) ) // tryBuildNativeBoolRules attempts to build a native Go evaluator for @@ -39,9 +42,11 @@ func tryBuildNativeBoolRules(base base, rules *validate.BoolRules) evaluator { if !rules.HasConst() { return nil } + constVal := rules.GetConst() + rules.ProtoReflect().Clear(boolConstSite.desc) return nativeBoolEval{ base: base, - constVal: rules.GetConst(), + constVal: constVal, } } @@ -55,7 +60,7 @@ type nativeBoolEval struct { func (n nativeBoolEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, _ *validationConfig) error { if val.Bool() != n.constVal { - return &ValidationError{Violations: []*Violation{n.newViolation(boolRuleDesc, boolConstDesc, + return &ValidationError{Violations: []*Violation{n.newViolation(boolConstSite, "bool.const", fmt.Sprintf("must equal %t", n.constVal), val, protoreflect.ValueOfBool(n.constVal)), }} diff --git a/native_bytes.go b/native_bytes.go index 88f72543..e5e0d217 100644 --- a/native_bytes.go +++ b/native_bytes.go @@ -46,15 +46,19 @@ func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { switch { case rules.GetIp(): wellKnown = &bytesWellKnownIP + rules.ProtoReflect().Clear(bytesDescs.ipDesc) hasRule = true case rules.GetIpv4(): wellKnown = &bytesWellKnownIPv4 + rules.ProtoReflect().Clear(bytesDescs.ipv4Desc) hasRule = true case rules.GetIpv6(): wellKnown = &bytesWellKnownIPv6 + rules.ProtoReflect().Clear(bytesDescs.ipv6Desc) hasRule = true case rules.GetUuid(): wellKnown = &bytesWellKnownUUID + rules.ProtoReflect().Clear(bytesDescs.uuidDesc) hasRule = true } @@ -63,24 +67,28 @@ func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { if rules.HasConst() { constVal = rules.GetConst() hasConst = true + rules.ProtoReflect().Clear(bytesDescs.constSite.desc) hasRule = true } var exactLen *uint64 if rules.HasLen() { exactLen = ptr(rules.GetLen()) + rules.ProtoReflect().Clear(bytesDescs.lenSite.desc) hasRule = true } var minLen uint64 if rules.HasMinLen() { minLen = rules.GetMinLen() + rules.ProtoReflect().Clear(bytesDescs.minLenSite.desc) hasRule = true } var maxLen uint64 = math.MaxUint64 if rules.HasMaxLen() { maxLen = rules.GetMaxLen() + rules.ProtoReflect().Clear(bytesDescs.maxLenSite.desc) hasRule = true } @@ -93,6 +101,7 @@ func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { if err != nil { return nil // bail to CEL } + rules.ProtoReflect().Clear(bytesDescs.patternSite.desc) hasRule = true } @@ -101,6 +110,7 @@ func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { if rules.HasPrefix() { prefix = rules.GetPrefix() hasPrefix = true + rules.ProtoReflect().Clear(bytesDescs.prefixSite.desc) hasRule = true } @@ -109,6 +119,7 @@ func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { if rules.HasSuffix() { suffix = rules.GetSuffix() hasSuffix = true + rules.ProtoReflect().Clear(bytesDescs.suffixSite.desc) hasRule = true } @@ -117,16 +128,19 @@ func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { if rules.HasContains() { contains = rules.GetContains() hasContains = true + rules.ProtoReflect().Clear(bytesDescs.containsSite.desc) hasRule = true } var inVals [][]byte if inVals = rules.GetIn(); len(inVals) > 0 { + rules.ProtoReflect().Clear(bytesDescs.inSite.desc) hasRule = true } var notInVals [][]byte if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + rules.ProtoReflect().Clear(bytesDescs.notInSite.desc) hasRule = true } @@ -157,7 +171,7 @@ func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { // bytesWellKnown identifies which well-known bytes format constraint is active. type bytesWellKnown struct { - desc protoreflect.FieldDescriptor + site ruleSite // pre-built rule path site for the error path ruleID, emptyRuleID string mainMsg, emptyMsg string validSizes []int @@ -166,7 +180,7 @@ type bytesWellKnown struct { var ( //nolint:gochecknoglobals bytesWellKnownIP = bytesWellKnown{ - desc: bytesDescs.ipDesc, + site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipDesc), ruleID: "bytes.ip", emptyRuleID: "bytes.ip_empty", mainMsg: "must be a valid IP address", @@ -175,7 +189,7 @@ var ( } //nolint:gochecknoglobals bytesWellKnownIPv4 = bytesWellKnown{ - desc: bytesDescs.ipv4Desc, + site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipv4Desc), ruleID: "bytes.ipv4", emptyRuleID: "bytes.ipv4_empty", mainMsg: "must be a valid IPv4 address", @@ -184,7 +198,7 @@ var ( } //nolint:gochecknoglobals bytesWellKnownIPv6 = bytesWellKnown{ - desc: bytesDescs.ipv6Desc, + site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipv6Desc), ruleID: "bytes.ipv6", emptyRuleID: "bytes.ipv6_empty", mainMsg: "must be a valid IPv6 address", @@ -193,7 +207,7 @@ var ( } //nolint:gochecknoglobals bytesWellKnownUUID = bytesWellKnown{ - desc: bytesDescs.uuidDesc, + site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.uuidDesc), ruleID: "bytes.uuid", emptyRuleID: "bytes.uuid_empty", mainMsg: "must be a valid UUID", @@ -204,42 +218,45 @@ var ( // bytesDescriptors bundles the field descriptors for BytesRules. type bytesDescriptors struct { - ruleDesc protoreflect.FieldDescriptor - constDesc protoreflect.FieldDescriptor - lenDesc protoreflect.FieldDescriptor - minLenDesc protoreflect.FieldDescriptor - maxLenDesc protoreflect.FieldDescriptor - patternDesc protoreflect.FieldDescriptor - prefixDesc protoreflect.FieldDescriptor - suffixDesc protoreflect.FieldDescriptor - containsDesc protoreflect.FieldDescriptor - inDesc protoreflect.FieldDescriptor - notInDesc protoreflect.FieldDescriptor - ipDesc protoreflect.FieldDescriptor - ipv4Desc protoreflect.FieldDescriptor - ipv6Desc protoreflect.FieldDescriptor - uuidDesc protoreflect.FieldDescriptor + ruleDesc protoreflect.FieldDescriptor + ipDesc protoreflect.FieldDescriptor + ipv4Desc protoreflect.FieldDescriptor + ipv6Desc protoreflect.FieldDescriptor + uuidDesc protoreflect.FieldDescriptor + + // Pre-built rule sites for the error path. + constSite ruleSite + lenSite ruleSite + minLenSite ruleSite + maxLenSite ruleSite + patternSite ruleSite + prefixSite ruleSite + suffixSite ruleSite + containsSite ruleSite + inSite ruleSite + notInSite ruleSite } func makeBytesDescriptors() bytesDescriptors { rulesDesc := (*validate.BytesRules)(nil).ProtoReflect().Descriptor() - return bytesDescriptors{ - ruleDesc: fieldRulesDesc.Fields().ByName("bytes"), - constDesc: rulesDesc.Fields().ByName("const"), - lenDesc: rulesDesc.Fields().ByName("len"), - minLenDesc: rulesDesc.Fields().ByName("min_len"), - maxLenDesc: rulesDesc.Fields().ByName("max_len"), - patternDesc: rulesDesc.Fields().ByName("pattern"), - prefixDesc: rulesDesc.Fields().ByName("prefix"), - suffixDesc: rulesDesc.Fields().ByName("suffix"), - containsDesc: rulesDesc.Fields().ByName("contains"), - inDesc: rulesDesc.Fields().ByName("in"), - notInDesc: rulesDesc.Fields().ByName("not_in"), - ipDesc: rulesDesc.Fields().ByName("ip"), - ipv4Desc: rulesDesc.Fields().ByName("ipv4"), - ipv6Desc: rulesDesc.Fields().ByName("ipv6"), - uuidDesc: rulesDesc.Fields().ByName("uuid"), - } + descriptors := bytesDescriptors{ + ruleDesc: fieldRulesDesc.Fields().ByName("bytes"), + ipDesc: rulesDesc.Fields().ByName("ip"), + ipv4Desc: rulesDesc.Fields().ByName("ipv4"), + ipv6Desc: rulesDesc.Fields().ByName("ipv6"), + uuidDesc: rulesDesc.Fields().ByName("uuid"), + } + descriptors.constSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("const"), "bytes.const") + descriptors.lenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("len"), "bytes.len") + descriptors.minLenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_len"), "bytes.min_len") + descriptors.maxLenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_len"), "bytes.max_len") + descriptors.patternSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("pattern"), "bytes.pattern") + descriptors.prefixSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("prefix"), "bytes.prefix") + descriptors.suffixSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("suffix"), "bytes.suffix") + descriptors.containsSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("contains"), "bytes.contains") + descriptors.inSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("in"), "bytes.in") + descriptors.notInSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_in"), "bytes.not_in") + return descriptors } //nolint:gochecknoglobals @@ -277,7 +294,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value var violations []*Violation if n.hasConst && !bytes.Equal(bytesVal, n.constVal) { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.constDesc, + violations = append(violations, n.newViolation(bytesDescs.constSite, "bytes.const", fmt.Sprintf("must be %x", n.constVal), val, protoreflect.ValueOfBytes(n.constVal))) if cfg.failFast { @@ -286,7 +303,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } if n.exactLen != nil && byteLen != *n.exactLen { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.lenDesc, + violations = append(violations, n.newViolation(bytesDescs.lenSite, "bytes.len", fmt.Sprintf("must be %d bytes", *n.exactLen), val, protoreflect.ValueOfUint64(*n.exactLen))) if cfg.failFast { @@ -295,7 +312,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } if byteLen < n.minLen { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.minLenDesc, + violations = append(violations, n.newViolation(bytesDescs.minLenSite, "bytes.min_len", fmt.Sprintf("must be at least %d bytes", n.minLen), val, protoreflect.ValueOfUint64(n.minLen))) if cfg.failFast { @@ -304,7 +321,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } if byteLen > n.maxLen { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.maxLenDesc, + violations = append(violations, n.newViolation(bytesDescs.maxLenSite, "bytes.max_len", fmt.Sprintf("must be at most %d bytes", n.maxLen), val, protoreflect.ValueOfUint64(n.maxLen))) if cfg.failFast { @@ -319,7 +336,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value return &RuntimeError{cause: errNotUTF8} } if !n.pattern.MatchString(string(bytesVal)) { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.patternDesc, + violations = append(violations, n.newViolation(bytesDescs.patternSite, "bytes.pattern", fmt.Sprintf("must match regex pattern `%s`", n.patternStr), val, protoreflect.ValueOfString(n.patternStr))) if cfg.failFast { @@ -329,7 +346,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } if n.hasPrefix && !bytes.HasPrefix(bytesVal, n.prefix) { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.prefixDesc, + violations = append(violations, n.newViolation(bytesDescs.prefixSite, "bytes.prefix", fmt.Sprintf("does not have prefix %x", n.prefix), val, protoreflect.ValueOfBytes(n.prefix))) if cfg.failFast { @@ -338,7 +355,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } if n.hasSuffix && !bytes.HasSuffix(bytesVal, n.suffix) { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.suffixDesc, + violations = append(violations, n.newViolation(bytesDescs.suffixSite, "bytes.suffix", fmt.Sprintf("does not have suffix %x", n.suffix), val, protoreflect.ValueOfBytes(n.suffix))) if cfg.failFast { @@ -347,7 +364,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } if n.hasContains && !bytes.Contains(bytesVal, n.contains) { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.containsDesc, + violations = append(violations, n.newViolation(bytesDescs.containsSite, "bytes.contains", fmt.Sprintf("does not contain %x", n.contains), val, protoreflect.ValueOfBytes(n.contains))) if cfg.failFast { @@ -356,7 +373,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } if len(n.inVals) > 0 && !slices.ContainsFunc(n.inVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.inDesc, + violations = append(violations, n.newViolation(bytesDescs.inSite, "bytes.in", "must be in list "+formatBytesList(n.inVals), val, protoreflect.ValueOfBytes(bytesVal))) if cfg.failFast { @@ -365,7 +382,7 @@ func (n nativeBytesEval) Evaluate(_ protoreflect.Message, val protoreflect.Value } if len(n.notInVals) > 0 && slices.ContainsFunc(n.notInVals, func(v []byte) bool { return bytes.Equal(v, bytesVal) }) { - violations = append(violations, n.newViolation(bytesDescs.ruleDesc, bytesDescs.notInDesc, + violations = append(violations, n.newViolation(bytesDescs.notInSite, "bytes.not_in", "must not be in list "+formatBytesList(n.notInVals), val, protoreflect.ValueOfBytes(bytesVal))) if cfg.failFast { @@ -395,7 +412,7 @@ func (n nativeBytesEval) evaluateWellKnown(bytesVal []byte, val protoreflect.Val wellKnown := n.wellKnown if size == 0 { - return n.newViolation(bytesDescs.ruleDesc, wellKnown.desc, + return n.newViolation(wellKnown.site, wellKnown.emptyRuleID, wellKnown.emptyMsg, val, protoreflect.ValueOfBool(true)) } @@ -404,7 +421,7 @@ func (n nativeBytesEval) evaluateWellKnown(bytesVal []byte, val protoreflect.Val return nil } - return n.newViolation(bytesDescs.ruleDesc, wellKnown.desc, + return n.newViolation(wellKnown.site, wellKnown.ruleID, wellKnown.mainMsg, val, protoreflect.ValueOfBool(true)) } diff --git a/native_enum.go b/native_enum.go index 3b4af937..2e8224e0 100644 --- a/native_enum.go +++ b/native_enum.go @@ -24,9 +24,9 @@ import ( //nolint:gochecknoglobals var ( - enumConstDesc = (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const") - enumInDesc = (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("in") - enumNotInDesc = (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("not_in") + enumConstSite = makeRuleSiteWithID(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const"), "enum.const") + enumInSite = makeRuleSiteWithID(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("in"), "enum.in") + enumNotInSite = makeRuleSiteWithID(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("not_in"), "enum.not_in") ) // tryBuildNativeEnumRules attempts to build a native Go evaluator for @@ -45,16 +45,19 @@ func tryBuildNativeEnumRules(base base, rules *validate.EnumRules) evaluator { var constVal *int32 if rules.HasConst() { constVal = ptr(rules.GetConst()) + rules.ProtoReflect().Clear(enumConstSite.desc) hasRule = true } var inVals []int32 if inVals = rules.GetIn(); len(inVals) > 0 { + rules.ProtoReflect().Clear(enumInSite.desc) hasRule = true } var notInVals []int32 if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + rules.ProtoReflect().Clear(enumNotInSite.desc) hasRule = true } @@ -87,7 +90,7 @@ var enumProcessors = []enumProcessor{ // const func(n nativeEnumEval, val protoreflect.Value, enumVal int32) *Violation { if n.constVal != nil && enumVal != *n.constVal { - return n.newViolation(enumRuleDescriptor, enumConstDesc, + return n.newViolation(enumConstSite, "enum.const", fmt.Sprintf("must equal %d", *n.constVal), val, protoreflect.ValueOfInt32(*n.constVal)) } @@ -96,7 +99,7 @@ var enumProcessors = []enumProcessor{ // in func(n nativeEnumEval, val protoreflect.Value, enumVal int32) *Violation { if len(n.inVals) > 0 && !slices.Contains(n.inVals, enumVal) { - return n.newViolation(enumRuleDescriptor, enumInDesc, + return n.newViolation(enumInSite, "enum.in", "must be in list "+formatList(n.inVals), val, protoreflect.ValueOfInt32(enumVal)) } @@ -105,7 +108,7 @@ var enumProcessors = []enumProcessor{ // not_in func(n nativeEnumEval, val protoreflect.Value, enumVal int32) *Violation { if len(n.notInVals) > 0 && slices.Contains(n.notInVals, enumVal) { - return n.newViolation(enumRuleDescriptor, enumNotInDesc, + return n.newViolation(enumNotInSite, "enum.not_in", "must not be in list "+formatList(n.notInVals), val, protoreflect.ValueOfInt32(enumVal)) } diff --git a/native_map.go b/native_map.go index e8075452..2496be26 100644 --- a/native_map.go +++ b/native_map.go @@ -24,8 +24,16 @@ import ( //nolint:gochecknoglobals var ( - mapMinPairsDesc = (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("min_pairs") - mapMaxPairsDesc = (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_pairs") + mapMinPairsSite = makeRuleSiteWithID( + mapFieldRulesDesc, + (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("min_pairs"), + "map.min_pairs", + ) + mapMaxPairsSite = makeRuleSiteWithID( + mapFieldRulesDesc, + (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_pairs"), + "map.max_pairs", + ) ) // tryNativeMapRules attempts to build a native Go evaluator for @@ -44,12 +52,14 @@ func tryNativeMapRules(base base, rules *validate.MapRules) evaluator { var minPairs uint64 if rules.HasMinPairs() { minPairs = rules.GetMinPairs() + rules.ProtoReflect().Clear(mapMinPairsSite.desc) hasRule = true } var maxPairs uint64 = math.MaxUint64 if rules.HasMaxPairs() { maxPairs = rules.GetMaxPairs() + rules.ProtoReflect().Clear(mapMaxPairsSite.desc) hasRule = true } @@ -80,7 +90,7 @@ func (n nativeMapEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, // min_pairs if size < n.minPairs { - return &ValidationError{Violations: []*Violation{n.newViolation(mapFieldRulesDesc, mapMinPairsDesc, + return &ValidationError{Violations: []*Violation{n.newViolation(mapMinPairsSite, "map.min_pairs", fmt.Sprintf("map must be at least %d entries", n.minPairs), val, protoreflect.ValueOfUint64(n.minPairs)), @@ -89,7 +99,7 @@ func (n nativeMapEval) Evaluate(_ protoreflect.Message, val protoreflect.Value, // max_pairs if size > n.maxPairs { - return &ValidationError{Violations: []*Violation{n.newViolation(mapFieldRulesDesc, mapMaxPairsDesc, + return &ValidationError{Violations: []*Violation{n.newViolation(mapMaxPairsSite, "map.max_pairs", fmt.Sprintf("map must be at most %d entries", n.maxPairs), val, protoreflect.ValueOfUint64(n.maxPairs)), diff --git a/native_numeric.go b/native_numeric.go index 9e0f5928..d9c5b2ad 100644 --- a/native_numeric.go +++ b/native_numeric.go @@ -36,84 +36,84 @@ func tryBuildNativeInt32Rules(base base, rules *validate.Int32Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, int32Config) + return tryBuildNativeNumericRules(base, rules, &int32Config) } func tryBuildNativeSint32Rules(base base, rules *validate.SInt32Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, sint32Config) + return tryBuildNativeNumericRules(base, rules, &sint32Config) } func tryBuildNativeSfixed32Rules(base base, rules *validate.SFixed32Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, sfixed32Config) + return tryBuildNativeNumericRules(base, rules, &sfixed32Config) } func tryBuildNativeInt64Rules(base base, rules *validate.Int64Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, int64Config) + return tryBuildNativeNumericRules(base, rules, &int64Config) } func tryBuildNativeSint64Rules(base base, rules *validate.SInt64Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, sint64Config) + return tryBuildNativeNumericRules(base, rules, &sint64Config) } func tryBuildNativeSfixed64Rules(base base, rules *validate.SFixed64Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, sfixed64Config) + return tryBuildNativeNumericRules(base, rules, &sfixed64Config) } func tryBuildNativeUint32Rules(base base, rules *validate.UInt32Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, uint32Config) + return tryBuildNativeNumericRules(base, rules, &uint32Config) } func tryBuildNativeFixed32Rules(base base, rules *validate.Fixed32Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, fixed32Config) + return tryBuildNativeNumericRules(base, rules, &fixed32Config) } func tryBuildNativeUint64Rules(base base, rules *validate.UInt64Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, uint64Config) + return tryBuildNativeNumericRules(base, rules, &uint64Config) } func tryBuildNativeFixed64Rules(base base, rules *validate.Fixed64Rules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, fixed64Config) + return tryBuildNativeNumericRules(base, rules, &fixed64Config) } func tryBuildNativeFloatRules(base base, rules *validate.FloatRules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, floatConfig) + return tryBuildNativeNumericRules(base, rules, &floatConfig) } func tryBuildNativeDoubleRules(base base, rules *validate.DoubleRules) evaluator { if rules == nil { return nil } - return tryBuildNativeNumericRules(base, rules, doubleConfig) + return tryBuildNativeNumericRules(base, rules, &doubleConfig) } // tryBuildNativeNumericRules attempts to build a native Go evaluator for @@ -122,7 +122,7 @@ func tryBuildNativeDoubleRules(base base, rules *validate.DoubleRules) evaluator func tryBuildNativeNumericRules[T numericValue, R numericRules[T]]( base base, rules R, - config numericTypeConfig[T], + config *numericTypeConfig[T], ) evaluator { // Bail out if the rules message has unknown fields, which indicate // custom predefined extensions that we can't handle natively. @@ -138,10 +138,12 @@ func tryBuildNativeNumericRules[T numericValue, R numericRules[T]]( case rules.HasGt(): lower = lowerBoundGt lowerValue = rules.GetGt() + rules.ProtoReflect().Clear(config.descs.gtSite.desc) hasRule = true case rules.HasGte(): lower = lowerBoundGte lowerValue = rules.GetGte() + rules.ProtoReflect().Clear(config.descs.gteSite.desc) hasRule = true } @@ -151,26 +153,31 @@ func tryBuildNativeNumericRules[T numericValue, R numericRules[T]]( case rules.HasLt(): upper = upperBoundLt upperValue = rules.GetLt() + rules.ProtoReflect().Clear(config.descs.ltSite.desc) hasRule = true case rules.HasLte(): upper = upperBoundLte upperValue = rules.GetLte() + rules.ProtoReflect().Clear(config.descs.lteSite.desc) hasRule = true } var constVal *T if rules.HasConst() { constVal = ptr(rules.GetConst()) + rules.ProtoReflect().Clear(config.descs.constSite.desc) hasRule = true } var inVals []T if inVals = rules.GetIn(); len(inVals) > 0 { + rules.ProtoReflect().Clear(config.descs.inSite.desc) hasRule = true } var notInVals []T if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + rules.ProtoReflect().Clear(config.descs.notInSite.desc) hasRule = true } @@ -182,6 +189,7 @@ func tryBuildNativeNumericRules[T numericValue, R numericRules[T]]( finite := false if fi, ok := (any)(rules).(finiteInterface); ok && fi.HasFinite() { finite = fi.GetFinite() + rules.ProtoReflect().Clear(config.descs.finiteSite.desc) hasRule = true } @@ -223,40 +231,45 @@ type numericRules[T numericValue] interface { ProtoReflect() protoreflect.Message } -// numericDescriptors bundles the field descriptors for a single numeric -// rules type (e.g., Int32Rules). Used to construct violation rule paths. +// numericDescriptors bundles the pre-built rule sites for a single numeric +// rules type (e.g., Int32Rules). A ruleSite carries both the rule-path +// FieldPathElements and the leaf descriptor, so the individual per-rule +// descriptor fields are not needed. type numericDescriptors struct { - ruleDesc protoreflect.FieldDescriptor // FieldRules.{type} (e.g., "int32") - gtDesc protoreflect.FieldDescriptor - gteDesc protoreflect.FieldDescriptor - ltDesc protoreflect.FieldDescriptor - lteDesc protoreflect.FieldDescriptor - constDesc protoreflect.FieldDescriptor - inDesc protoreflect.FieldDescriptor - notInDesc protoreflect.FieldDescriptor - finiteDesc protoreflect.FieldDescriptor + gtSite ruleSite + gteSite ruleSite + ltSite ruleSite + lteSite ruleSite + constSite ruleSite + inSite ruleSite + notInSite ruleSite + finiteSite ruleSite // zero-valued for non-float kinds } func makeNumericDescriptors( fieldName string, rulesMsg protoreflect.ProtoMessage, + typeName string, ) numericDescriptors { rulesDesc := rulesMsg.ProtoReflect().Descriptor() + ruleDesc := fieldRulesDesc.Fields().ByName(protoreflect.Name(fieldName)) var finiteDesc protoreflect.FieldDescriptor if rulesDesc.Name() == "FloatRules" || rulesDesc.Name() == "DoubleRules" { finiteDesc = rulesDesc.Fields().ByName("finite") } - return numericDescriptors{ - ruleDesc: fieldRulesDesc.Fields().ByName(protoreflect.Name(fieldName)), - gtDesc: rulesDesc.Fields().ByName("gt"), - gteDesc: rulesDesc.Fields().ByName("gte"), - ltDesc: rulesDesc.Fields().ByName("lt"), - lteDesc: rulesDesc.Fields().ByName("lte"), - constDesc: rulesDesc.Fields().ByName("const"), - inDesc: rulesDesc.Fields().ByName("in"), - notInDesc: rulesDesc.Fields().ByName("not_in"), - finiteDesc: finiteDesc, + descriptors := numericDescriptors{ + gtSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("gt")), + gteSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("gte")), + ltSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("lt")), + lteSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("lte")), + constSite: makeRuleSiteWithID(ruleDesc, rulesDesc.Fields().ByName("const"), typeName+".const"), + inSite: makeRuleSiteWithID(ruleDesc, rulesDesc.Fields().ByName("in"), typeName+".in"), + notInSite: makeRuleSiteWithID(ruleDesc, rulesDesc.Fields().ByName("not_in"), typeName+".not_in"), } + if finiteDesc != nil { + descriptors.finiteSite = makeRuleSiteWithIDAndMessage(ruleDesc, finiteDesc, typeName+".finite", "must be finite") + } + return descriptors } // numericTypeConfig holds all type-specific operations and metadata @@ -269,94 +282,78 @@ type numericTypeConfig[T numericValue] struct { nanFailsRange bool // true only for float32, float64 } -//nolint:gochecknoglobals -var ( - int32Descs = makeNumericDescriptors("int32", (*validate.Int32Rules)(nil)) - sint32Descs = makeNumericDescriptors("sint32", (*validate.SInt32Rules)(nil)) - sfixed32Descs = makeNumericDescriptors("sfixed32", (*validate.SFixed32Rules)(nil)) - int64Descs = makeNumericDescriptors("int64", (*validate.Int64Rules)(nil)) - sint64Descs = makeNumericDescriptors("sint64", (*validate.SInt64Rules)(nil)) - sfixed64Descs = makeNumericDescriptors("sfixed64", (*validate.SFixed64Rules)(nil)) - uint32Descs = makeNumericDescriptors("uint32", (*validate.UInt32Rules)(nil)) - fixed32Descs = makeNumericDescriptors("fixed32", (*validate.Fixed32Rules)(nil)) - uint64Descs = makeNumericDescriptors("uint64", (*validate.UInt64Rules)(nil)) - fixed64Descs = makeNumericDescriptors("fixed64", (*validate.Fixed64Rules)(nil)) - floatDescs = makeNumericDescriptors("float", (*validate.FloatRules)(nil)) - doubleDescs = makeNumericDescriptors("double", (*validate.DoubleRules)(nil)) -) - //nolint:gochecknoglobals var ( int32Config = numericTypeConfig[int32]{ typeName: "int32", - descs: int32Descs, + descs: makeNumericDescriptors("int32", (*validate.Int32Rules)(nil), "int32"), extractVal: func(v protoreflect.Value) int32 { return int32(v.Int()) }, makeRuleVal: protoreflect.ValueOfInt32, } sint32Config = numericTypeConfig[int32]{ typeName: "sint32", - descs: sint32Descs, + descs: makeNumericDescriptors("sint32", (*validate.SInt32Rules)(nil), "sint32"), extractVal: func(v protoreflect.Value) int32 { return int32(v.Int()) }, makeRuleVal: protoreflect.ValueOfInt32, } sfixed32Config = numericTypeConfig[int32]{ typeName: "sfixed32", - descs: sfixed32Descs, + descs: makeNumericDescriptors("sfixed32", (*validate.SFixed32Rules)(nil), "sfixed32"), extractVal: func(v protoreflect.Value) int32 { return int32(v.Int()) }, makeRuleVal: protoreflect.ValueOfInt32, } int64Config = numericTypeConfig[int64]{ typeName: "int64", - descs: int64Descs, + descs: makeNumericDescriptors("int64", (*validate.Int64Rules)(nil), "int64"), extractVal: func(v protoreflect.Value) int64 { return v.Int() }, makeRuleVal: protoreflect.ValueOfInt64, } sint64Config = numericTypeConfig[int64]{ typeName: "sint64", - descs: sint64Descs, + descs: makeNumericDescriptors("sint64", (*validate.SInt64Rules)(nil), "sint64"), extractVal: func(v protoreflect.Value) int64 { return v.Int() }, makeRuleVal: protoreflect.ValueOfInt64, } sfixed64Config = numericTypeConfig[int64]{ typeName: "sfixed64", - descs: sfixed64Descs, + descs: makeNumericDescriptors("sfixed64", (*validate.SFixed64Rules)(nil), "sfixed64"), extractVal: func(v protoreflect.Value) int64 { return v.Int() }, makeRuleVal: protoreflect.ValueOfInt64, } uint32Config = numericTypeConfig[uint32]{ typeName: "uint32", - descs: uint32Descs, + descs: makeNumericDescriptors("uint32", (*validate.UInt32Rules)(nil), "uint32"), extractVal: func(v protoreflect.Value) uint32 { return uint32(v.Uint()) }, makeRuleVal: protoreflect.ValueOfUint32, } fixed32Config = numericTypeConfig[uint32]{ typeName: "fixed32", - descs: fixed32Descs, + descs: makeNumericDescriptors("fixed32", (*validate.Fixed32Rules)(nil), "fixed32"), extractVal: func(v protoreflect.Value) uint32 { return uint32(v.Uint()) }, makeRuleVal: protoreflect.ValueOfUint32, } uint64Config = numericTypeConfig[uint64]{ typeName: "uint64", - descs: uint64Descs, + descs: makeNumericDescriptors("uint64", (*validate.UInt64Rules)(nil), "uint64"), extractVal: func(v protoreflect.Value) uint64 { return v.Uint() }, makeRuleVal: protoreflect.ValueOfUint64, } fixed64Config = numericTypeConfig[uint64]{ typeName: "fixed64", - descs: fixed64Descs, + descs: makeNumericDescriptors("fixed64", (*validate.Fixed64Rules)(nil), "fixed64"), extractVal: func(v protoreflect.Value) uint64 { return v.Uint() }, makeRuleVal: protoreflect.ValueOfUint64, } floatConfig = numericTypeConfig[float32]{ typeName: "float", - descs: floatDescs, + descs: makeNumericDescriptors("float", (*validate.FloatRules)(nil), "float"), extractVal: func(v protoreflect.Value) float32 { return float32(v.Float()) }, makeRuleVal: protoreflect.ValueOfFloat32, nanFailsRange: true, } doubleConfig = numericTypeConfig[float64]{ typeName: "double", - descs: doubleDescs, + descs: makeNumericDescriptors("double", (*validate.DoubleRules)(nil), "double"), extractVal: func(v protoreflect.Value) float64 { return v.Float() }, makeRuleVal: protoreflect.ValueOfFloat64, nanFailsRange: true, @@ -385,9 +382,13 @@ const ( // nativeNumericCompare is a native Go evaluator for numeric gt/gte/lt/lte/ // const/in/not_in rules. It replaces CEL evaluation with direct Go comparisons. +// +// config is stored as a pointer so the (globally shared, read-only) config +// struct is not copied into every evaluator (and then again onto the stack +// via the value receiver on Evaluate). type nativeNumericCompare[T numericValue] struct { base - config numericTypeConfig[T] + config *numericTypeConfig[T] lo T // lower bound value (gt or gte threshold) lower lowerBound // gt (exclusive) or gte (inclusive) hi T // upper bound value (lt or lte threshold) @@ -419,18 +420,18 @@ func (n nativeNumericCompare[T]) isNormalRange() bool { return n.hi >= n.lo } -func (n nativeNumericCompare[T]) loDesc() protoreflect.FieldDescriptor { +func (n nativeNumericCompare[T]) loSite() ruleSite { if n.lower == lowerBoundGt { - return n.config.descs.gtDesc + return n.config.descs.gtSite } - return n.config.descs.gteDesc + return n.config.descs.gteSite } -func (n nativeNumericCompare[T]) hiDesc() protoreflect.FieldDescriptor { +func (n nativeNumericCompare[T]) hiSite() ruleSite { if n.upper == upperBoundLt { - return n.config.descs.ltDesc + return n.config.descs.ltSite } - return n.config.descs.lteDesc + return n.config.descs.lteSite } func (n nativeNumericCompare[T]) gtRulePrefix() string { @@ -493,7 +494,7 @@ func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protorefle var violations []*Violation if n.constVal != nil && valT != *n.constVal { - violations = append(violations, n.newViolation(n.config.descs.ruleDesc, n.config.descs.constDesc, + violations = append(violations, n.newViolation(n.config.descs.constSite, n.config.typeName+".const", fmt.Sprintf("must equal %v", *n.constVal), val, n.config.makeRuleVal(*n.constVal))) @@ -503,7 +504,7 @@ func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protorefle } if len(n.inVals) > 0 && !slices.Contains(n.inVals, valT) { - violations = append(violations, n.newViolation(n.config.descs.ruleDesc, n.config.descs.inDesc, + violations = append(violations, n.newViolation(n.config.descs.inSite, n.config.typeName+".in", "must be in list "+formatList(n.inVals), val, n.config.makeRuleVal(valT))) @@ -513,7 +514,7 @@ func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protorefle } if len(n.notInVals) > 0 && slices.Contains(n.notInVals, valT) { - violations = append(violations, n.newViolation(n.config.descs.ruleDesc, n.config.descs.notInDesc, + violations = append(violations, n.newViolation(n.config.descs.notInSite, n.config.typeName+".not_in", "must not be in list "+formatList(n.notInVals), val, n.config.makeRuleVal(valT))) @@ -523,7 +524,7 @@ func (n nativeNumericCompare[T]) Evaluate(_ protoreflect.Message, val protorefle } if n.finite && (math.IsNaN(float64(valT)) || math.IsInf(float64(valT), 0)) { - violations = append(violations, n.newViolation(n.config.descs.ruleDesc, n.config.descs.finiteDesc, + violations = append(violations, n.newViolation(n.config.descs.finiteSite, n.config.typeName+".finite", "must be finite", val, n.config.makeRuleVal(valT))) @@ -561,13 +562,13 @@ func (n nativeNumericCompare[T]) evaluateRange(valT T, val protoreflect.Value) * switch { case n.lower == lowerBoundNone: if isNaN || n.aboveHi(valT) { - return n.newViolation(n.config.descs.ruleDesc, n.hiDesc(), + return n.newViolation(n.hiSite(), n.gtltRule(), "must be "+n.hiMessage(), val, n.config.makeRuleVal(n.hi)) } case n.upper == upperBoundNone: if isNaN || n.belowLo(valT) { - return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), + return n.newViolation(n.loSite(), n.gtltRule(), "must be "+n.loMessage(), val, n.config.makeRuleVal(n.lo)) } @@ -579,7 +580,7 @@ func (n nativeNumericCompare[T]) evaluateRange(valT T, val protoreflect.Value) * failure = isNaN || (n.aboveHi(valT) && n.belowLo(valT)) } if failure { - return n.newViolation(n.config.descs.ruleDesc, n.loDesc(), + return n.newViolation(n.loSite(), n.gtltRule(), fmt.Sprintf("must be %s %s %s", n.loMessage(), n.conjunction(), n.hiMessage()), val, n.config.makeRuleVal(n.lo)) diff --git a/native_numeric_test.go b/native_numeric_test.go index 37e724c9..d7acb985 100644 --- a/native_numeric_test.go +++ b/native_numeric_test.go @@ -19,6 +19,7 @@ import ( "testing" "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + examplev1 "buf.build/go/protovalidate/internal/gen/tests/example/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" @@ -217,7 +218,7 @@ func TestNativeInt32Compare(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - eval := buildNativeNumeric(t, tt.rules, int32Config, descriptorpb.FieldDescriptorProto_TYPE_INT32) + eval := buildNativeNumeric(t, tt.rules, &int32Config, descriptorpb.FieldDescriptorProto_TYPE_INT32) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfInt32, tt.cases) }) @@ -230,7 +231,7 @@ func TestNativeInt64Compare(t *testing.T) { t.Parallel() rules := validate.Int64Rules_builder{Gt: proto.Int64(0), Lt: proto.Int64(100)}.Build() - eval := buildNativeNumeric(t, rules, int64Config, descriptorpb.FieldDescriptorProto_TYPE_INT64) + eval := buildNativeNumeric(t, rules, &int64Config, descriptorpb.FieldDescriptorProto_TYPE_INT64) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfInt64, []numericTestCase[int64]{ @@ -244,7 +245,7 @@ func TestNativeUint32Compare(t *testing.T) { t.Parallel() rules := validate.UInt32Rules_builder{Gte: proto.Uint32(5), Lte: proto.Uint32(10)}.Build() - eval := buildNativeNumeric(t, rules, uint32Config, descriptorpb.FieldDescriptorProto_TYPE_UINT32) + eval := buildNativeNumeric(t, rules, &uint32Config, descriptorpb.FieldDescriptorProto_TYPE_UINT32) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfUint32, []numericTestCase[uint32]{ @@ -258,7 +259,7 @@ func TestNativeUint64Compare(t *testing.T) { t.Parallel() rules := validate.UInt64Rules_builder{Gt: proto.Uint64(0)}.Build() - eval := buildNativeNumeric(t, rules, uint64Config, descriptorpb.FieldDescriptorProto_TYPE_UINT64) + eval := buildNativeNumeric(t, rules, &uint64Config, descriptorpb.FieldDescriptorProto_TYPE_UINT64) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfUint64, []numericTestCase[uint64]{ @@ -272,7 +273,7 @@ func TestNativeSint32Compare(t *testing.T) { t.Parallel() rules := validate.SInt32Rules_builder{Gt: proto.Int32(-10), Lt: proto.Int32(10)}.Build() - eval := buildNativeNumeric(t, rules, sint32Config, descriptorpb.FieldDescriptorProto_TYPE_SINT32) + eval := buildNativeNumeric(t, rules, &sint32Config, descriptorpb.FieldDescriptorProto_TYPE_SINT32) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfInt32, []numericTestCase[int32]{ @@ -286,7 +287,7 @@ func TestNativeSint64Compare(t *testing.T) { t.Parallel() rules := validate.SInt64Rules_builder{Gte: proto.Int64(0)}.Build() - eval := buildNativeNumeric(t, rules, sint64Config, descriptorpb.FieldDescriptorProto_TYPE_SINT64) + eval := buildNativeNumeric(t, rules, &sint64Config, descriptorpb.FieldDescriptorProto_TYPE_SINT64) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfInt64, []numericTestCase[int64]{ @@ -300,7 +301,7 @@ func TestNativeFixed32Compare(t *testing.T) { t.Parallel() rules := validate.Fixed32Rules_builder{Lt: proto.Uint32(100)}.Build() - eval := buildNativeNumeric(t, rules, fixed32Config, descriptorpb.FieldDescriptorProto_TYPE_FIXED32) + eval := buildNativeNumeric(t, rules, &fixed32Config, descriptorpb.FieldDescriptorProto_TYPE_FIXED32) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfUint32, []numericTestCase[uint32]{ @@ -314,7 +315,7 @@ func TestNativeFixed64Compare(t *testing.T) { t.Parallel() rules := validate.Fixed64Rules_builder{Lte: proto.Uint64(50)}.Build() - eval := buildNativeNumeric(t, rules, fixed64Config, descriptorpb.FieldDescriptorProto_TYPE_FIXED64) + eval := buildNativeNumeric(t, rules, &fixed64Config, descriptorpb.FieldDescriptorProto_TYPE_FIXED64) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfUint64, []numericTestCase[uint64]{ @@ -328,7 +329,7 @@ func TestNativeSfixed32Compare(t *testing.T) { t.Parallel() rules := validate.SFixed32Rules_builder{Const: proto.Int32(42)}.Build() - eval := buildNativeNumeric(t, rules, sfixed32Config, descriptorpb.FieldDescriptorProto_TYPE_SFIXED32) + eval := buildNativeNumeric(t, rules, &sfixed32Config, descriptorpb.FieldDescriptorProto_TYPE_SFIXED32) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfInt32, []numericTestCase[int32]{ @@ -342,7 +343,7 @@ func TestNativeSfixed64Compare(t *testing.T) { t.Parallel() rules := validate.SFixed64Rules_builder{In: []int64{1, 2, 3}}.Build() - eval := buildNativeNumeric(t, rules, sfixed64Config, descriptorpb.FieldDescriptorProto_TYPE_SFIXED64) + eval := buildNativeNumeric(t, rules, &sfixed64Config, descriptorpb.FieldDescriptorProto_TYPE_SFIXED64) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfInt64, []numericTestCase[int64]{ @@ -356,7 +357,7 @@ func TestNativeFloatCompare(t *testing.T) { t.Parallel() rules := validate.FloatRules_builder{Gt: proto.Float32(0), Lt: proto.Float32(10)}.Build() - eval := buildNativeNumeric(t, rules, floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) + eval := buildNativeNumeric(t, rules, &floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfFloat32, []numericTestCase[float32]{ @@ -368,7 +369,7 @@ func TestNativeFloatFinite(t *testing.T) { t.Parallel() rules := validate.FloatRules_builder{Finite: proto.Bool(true)}.Build() - eval := buildNativeNumeric(t, rules, floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) + eval := buildNativeNumeric(t, rules, &floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfFloat32, []numericTestCase[float32]{ @@ -380,7 +381,7 @@ func TestNativeDoubleFinite(t *testing.T) { t.Parallel() rules := validate.DoubleRules_builder{Finite: proto.Bool(true)}.Build() - eval := buildNativeNumeric(t, rules, doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) + eval := buildNativeNumeric(t, rules, &doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfFloat64, []numericTestCase[float64]{ @@ -402,7 +403,7 @@ func TestNativeFloat_NaN(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - eval := buildNativeNumeric(t, tt.rules, floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) + eval := buildNativeNumeric(t, tt.rules, &floatConfig, descriptorpb.FieldDescriptorProto_TYPE_FLOAT) require.NotNil(t, eval) nan := float32(math.NaN()) err := eval.Evaluate(nil, protoreflect.ValueOfFloat32(nan), &validationConfig{}) @@ -426,7 +427,7 @@ func TestNativeDoubleCompare(t *testing.T) { t.Parallel() rules := validate.DoubleRules_builder{Gte: proto.Float64(-1.5), Lte: proto.Float64(1.5)}.Build() - eval := buildNativeNumeric(t, rules, doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) + eval := buildNativeNumeric(t, rules, &doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) require.NotNil(t, eval) runNumericCases(t, eval, protoreflect.ValueOfFloat64, []numericTestCase[float64]{ @@ -438,7 +439,7 @@ func TestNativeDouble_NaN(t *testing.T) { t.Parallel() rules := validate.DoubleRules_builder{Gt: proto.Float64(0)}.Build() - eval := buildNativeNumeric(t, rules, doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) + eval := buildNativeNumeric(t, rules, &doubleConfig, descriptorpb.FieldDescriptorProto_TYPE_DOUBLE) require.NotNil(t, eval) err := eval.Evaluate(nil, protoreflect.ValueOfFloat64(math.NaN()), &validationConfig{}) @@ -451,7 +452,7 @@ func TestNativeNumericCompare_FieldValue(t *testing.T) { t.Parallel() eval := buildNativeNumeric(t, validate.Int32Rules_builder{Gt: proto.Int32(5)}.Build(), - int32Config, + &int32Config, descriptorpb.FieldDescriptorProto_TYPE_INT32, ) require.NotNil(t, eval) @@ -468,7 +469,7 @@ func TestNativeNumericCompare_Tautology(t *testing.T) { t.Parallel() eval := buildNativeNumeric(t, validate.Int32Rules_builder{Gt: proto.Int32(0)}.Build(), - int32Config, + &int32Config, descriptorpb.FieldDescriptorProto_TYPE_INT32, ) require.NotNil(t, eval) @@ -498,13 +499,25 @@ func TestTryBuildNativeNumericRules_ReturnsNil(t *testing.T) { } } +func TestMultiRule(t *testing.T) { + t.Parallel() + msg := examplev1.MultiRule_builder{Many: 1}.Build() + val, err := New(WithMessages(msg), WithDisableLazy()) + require.NoError(t, err) + err = val.Validate(msg) + var valErr *ValidationError + require.ErrorAs(t, err, &valErr) + require.Len(t, valErr.Violations, 2) + assert.Equal(t, int64(1), valErr.Violations[0].FieldValue.Int()) +} + // --- Helpers --- // buildNativeNumeric constructs a nativeNumericCompare evaluator for testing. func buildNativeNumeric[T numericValue, R numericRules[T]]( t testing.TB, rules R, - config numericTypeConfig[T], + config *numericTypeConfig[T], fieldType descriptorpb.FieldDescriptorProto_Type, ) evaluator { t.Helper() diff --git a/native_repeated.go b/native_repeated.go index 2c48639b..1ace3669 100644 --- a/native_repeated.go +++ b/native_repeated.go @@ -25,9 +25,22 @@ import ( //nolint:gochecknoglobals var ( - repeatedMinItemsDesc = (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("min_items") - repeatedMaxItemsDesc = (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_items") - repeatedUniqueDesc = (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("unique") + repeatedMinItemsSite = makeRuleSiteWithID( + repeatedFieldRulesDesc, + (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("min_items"), + "repeated.min_items", + ) + repeatedMaxItemsSite = makeRuleSiteWithID( + repeatedFieldRulesDesc, + (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_items"), + "repeated.max_items", + ) + repeatedUniqueSite = makeRuleSiteWithIDAndMessage( + repeatedFieldRulesDesc, + (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("unique"), + "repeated.unique", + "repeated value must contain unique items", + ) ) // tryNativeRepeatedRules attempts to build a native Go evaluator for @@ -46,12 +59,14 @@ func tryNativeRepeatedRules(base base, rules *validate.RepeatedRules) evaluator var minItems uint64 if rules.HasMinItems() { minItems = rules.GetMinItems() + rules.ProtoReflect().Clear(repeatedMinItemsSite.desc) hasRule = true } var maxItems uint64 = math.MaxUint64 if rules.HasMaxItems() { maxItems = rules.GetMaxItems() + rules.ProtoReflect().Clear(repeatedMaxItemsSite.desc) hasRule = true } @@ -63,6 +78,7 @@ func tryNativeRepeatedRules(base base, rules *validate.RepeatedRules) evaluator // natively; fall through to CEL. return nil } + rules.ProtoReflect().Clear(repeatedUniqueSite.desc) hasRule = true } @@ -138,7 +154,7 @@ func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Va var violations []*Violation if size < n.minItems { - violations = append(violations, n.newViolation(repeatedFieldRulesDesc, repeatedMinItemsDesc, + violations = append(violations, n.newViolation(repeatedMinItemsSite, "repeated.min_items", fmt.Sprintf("must contain at least %d item(s)", n.minItems), val, protoreflect.ValueOfUint64(n.minItems))) @@ -148,7 +164,7 @@ func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Va } if size > n.maxItems { - violations = append(violations, n.newViolation(repeatedFieldRulesDesc, repeatedMaxItemsDesc, + violations = append(violations, n.newViolation(repeatedMaxItemsSite, "repeated.max_items", fmt.Sprintf("must contain no more than %d item(s)", n.maxItems), val, protoreflect.ValueOfUint64(n.maxItems))) @@ -158,7 +174,7 @@ func (n nativeRepeatedEval) Evaluate(_ protoreflect.Message, val protoreflect.Va } if n.uniqueFn != nil && !n.uniqueFn(list) { - violations = append(violations, n.newViolation(repeatedFieldRulesDesc, repeatedUniqueDesc, + violations = append(violations, n.newViolation(repeatedUniqueSite, "repeated.unique", "repeated value must contain unique items", val, protoreflect.ValueOfBool(true))) diff --git a/native_string.go b/native_string.go index d238c2f0..d96b1045 100644 --- a/native_string.go +++ b/native_string.go @@ -51,48 +51,61 @@ func tryBuildNativeStringRules(base base, rules *validate.StringRules) evaluator if err != nil { return nil } + if wellKnownRule != nil { + rules.ProtoReflect().Clear(wellKnownRule.site.desc) + } + if knownRegex != validate.KnownRegex_KNOWN_REGEX_UNSPECIFIED { + rules.ProtoReflect().Clear(strDescs.wellKnownRegexSite.desc) + } hasRule = true } var constVal *string if rules.HasConst() { constVal = ptr(rules.GetConst()) + rules.ProtoReflect().Clear(strDescs.constSite.desc) hasRule = true } var exactLen *uint64 if rules.HasLen() { exactLen = ptr(rules.GetLen()) + rules.ProtoReflect().Clear(strDescs.lenSite.desc) hasRule = true } var minLen *uint64 if rules.HasMinLen() { minLen = ptr(rules.GetMinLen()) + rules.ProtoReflect().Clear(strDescs.minLenSite.desc) hasRule = true } var maxLen *uint64 if rules.HasMaxLen() { maxLen = ptr(rules.GetMaxLen()) + rules.ProtoReflect().Clear(strDescs.maxLenSite.desc) hasRule = true } var exactBytes *uint64 if rules.HasLenBytes() { exactBytes = ptr(rules.GetLenBytes()) + rules.ProtoReflect().Clear(strDescs.lenBytesSite.desc) hasRule = true } var minBytes *uint64 if rules.HasMinBytes() { minBytes = ptr(rules.GetMinBytes()) + rules.ProtoReflect().Clear(strDescs.minBytesSite.desc) hasRule = true } var maxBytes *uint64 if rules.HasMaxBytes() { maxBytes = ptr(rules.GetMaxBytes()) + rules.ProtoReflect().Clear(strDescs.maxBytesSite.desc) hasRule = true } @@ -106,40 +119,47 @@ func tryBuildNativeStringRules(base base, rules *validate.StringRules) evaluator // Invalid regex — bail to CEL which will also report a CompilationError. return nil } + rules.ProtoReflect().Clear(strDescs.patternSite.desc) hasRule = true } var prefix *string if rules.HasPrefix() { prefix = ptr(rules.GetPrefix()) + rules.ProtoReflect().Clear(strDescs.prefixSite.desc) hasRule = true } var suffix *string if rules.HasSuffix() { suffix = ptr(rules.GetSuffix()) + rules.ProtoReflect().Clear(strDescs.suffixSite.desc) hasRule = true } var containsVal *string if rules.HasContains() { containsVal = ptr(rules.GetContains()) + rules.ProtoReflect().Clear(strDescs.containsSite.desc) hasRule = true } var notContains *string if rules.HasNotContains() { notContains = ptr(rules.GetNotContains()) + rules.ProtoReflect().Clear(strDescs.notContainsSite.desc) hasRule = true } var inVals []string if inVals = rules.GetIn(); len(inVals) > 0 { + rules.ProtoReflect().Clear(strDescs.inSite.desc) hasRule = true } var notInVals []string if notInVals = rules.GetNotIn(); len(notInVals) > 0 { + rules.ProtoReflect().Clear(strDescs.notInSite.desc) hasRule = true } @@ -172,82 +192,47 @@ func tryBuildNativeStringRules(base base, rules *validate.StringRules) evaluator // stringDescriptors bundles the field descriptors for StringRules. type stringDescriptors struct { - ruleDesc protoreflect.FieldDescriptor // FieldRules.string - constDesc protoreflect.FieldDescriptor - lenDesc protoreflect.FieldDescriptor - minLenDesc protoreflect.FieldDescriptor - maxLenDesc protoreflect.FieldDescriptor - lenBytesDesc protoreflect.FieldDescriptor - minBytesDesc protoreflect.FieldDescriptor - maxBytesDesc protoreflect.FieldDescriptor - patternDesc protoreflect.FieldDescriptor - prefixDesc protoreflect.FieldDescriptor - suffixDesc protoreflect.FieldDescriptor - containsDesc protoreflect.FieldDescriptor - notContainsDesc protoreflect.FieldDescriptor - inDesc protoreflect.FieldDescriptor - notInDesc protoreflect.FieldDescriptor - hostNameDesc protoreflect.FieldDescriptor - hostAndPortDesc protoreflect.FieldDescriptor - emailDesc protoreflect.FieldDescriptor - ipDesc protoreflect.FieldDescriptor - ipv4Desc protoreflect.FieldDescriptor - ipv6Desc protoreflect.FieldDescriptor - uriDesc protoreflect.FieldDescriptor - uriRefDesc protoreflect.FieldDescriptor - addressDesc protoreflect.FieldDescriptor - uuidDesc protoreflect.FieldDescriptor - tuuidDesc protoreflect.FieldDescriptor - ipPrefixLenDesc protoreflect.FieldDescriptor - ipv4PrefixLenDesc protoreflect.FieldDescriptor - ipv6PrefixLenDesc protoreflect.FieldDescriptor - ipPrefixDesc protoreflect.FieldDescriptor - ipv4PrefixDesc protoreflect.FieldDescriptor - ipv6PrefixDesc protoreflect.FieldDescriptor - ulidDesc protoreflect.FieldDescriptor - wellKnownRegexDesc protoreflect.FieldDescriptor - strictDesc protoreflect.FieldDescriptor + ruleDesc protoreflect.FieldDescriptor // FieldRules.string + + // Pre-built rule sites for the error path. Each pairs ruleDesc with the + // corresponding leaf descriptor, built once at init. + constSite ruleSite + lenSite ruleSite + minLenSite ruleSite + maxLenSite ruleSite + lenBytesSite ruleSite + minBytesSite ruleSite + maxBytesSite ruleSite + patternSite ruleSite + prefixSite ruleSite + suffixSite ruleSite + containsSite ruleSite + notContainsSite ruleSite + inSite ruleSite + notInSite ruleSite + wellKnownRegexSite ruleSite } func makeStringDescriptors() stringDescriptors { - rulesDesc := (*validate.StringRules)(nil).ProtoReflect().Descriptor() - return stringDescriptors{ - ruleDesc: fieldRulesDesc.Fields().ByName("string"), - constDesc: rulesDesc.Fields().ByName("const"), - lenDesc: rulesDesc.Fields().ByName("len"), - minLenDesc: rulesDesc.Fields().ByName("min_len"), - maxLenDesc: rulesDesc.Fields().ByName("max_len"), - lenBytesDesc: rulesDesc.Fields().ByName("len_bytes"), - minBytesDesc: rulesDesc.Fields().ByName("min_bytes"), - maxBytesDesc: rulesDesc.Fields().ByName("max_bytes"), - patternDesc: rulesDesc.Fields().ByName("pattern"), - prefixDesc: rulesDesc.Fields().ByName("prefix"), - suffixDesc: rulesDesc.Fields().ByName("suffix"), - containsDesc: rulesDesc.Fields().ByName("contains"), - notContainsDesc: rulesDesc.Fields().ByName("not_contains"), - inDesc: rulesDesc.Fields().ByName("in"), - notInDesc: rulesDesc.Fields().ByName("not_in"), - emailDesc: rulesDesc.Fields().ByName("email"), - hostNameDesc: rulesDesc.Fields().ByName("hostname"), - ipDesc: rulesDesc.Fields().ByName("ip"), - ipv4Desc: rulesDesc.Fields().ByName("ipv4"), - ipv6Desc: rulesDesc.Fields().ByName("ipv6"), - uriDesc: rulesDesc.Fields().ByName("uri"), - uriRefDesc: rulesDesc.Fields().ByName("uri_ref"), - addressDesc: rulesDesc.Fields().ByName("address"), - uuidDesc: rulesDesc.Fields().ByName("uuid"), - tuuidDesc: rulesDesc.Fields().ByName("tuuid"), - ipPrefixLenDesc: rulesDesc.Fields().ByName("ip_with_prefixlen"), - ipv4PrefixLenDesc: rulesDesc.Fields().ByName("ipv4_with_prefixlen"), - ipv6PrefixLenDesc: rulesDesc.Fields().ByName("ipv6_with_prefixlen"), - ipPrefixDesc: rulesDesc.Fields().ByName("ip_prefix"), - ipv4PrefixDesc: rulesDesc.Fields().ByName("ipv4_prefix"), - ipv6PrefixDesc: rulesDesc.Fields().ByName("ipv6_prefix"), - hostAndPortDesc: rulesDesc.Fields().ByName("host_and_port"), - ulidDesc: rulesDesc.Fields().ByName("ulid"), - wellKnownRegexDesc: rulesDesc.Fields().ByName("well_known_regex"), - strictDesc: rulesDesc.Fields().ByName("strict"), - } + descriptors := stringDescriptors{ + ruleDesc: fieldRulesDesc.Fields().ByName("string"), + } + descriptors.constSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("const"), "string.const") + descriptors.lenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("len"), "string.len") + descriptors.minLenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_len"), "string.min_len") + descriptors.maxLenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_len"), "string.max_len") + descriptors.lenBytesSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("len_bytes"), "string.len_bytes") + descriptors.minBytesSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_bytes"), "string.min_bytes") + descriptors.maxBytesSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_bytes"), "string.max_bytes") + descriptors.patternSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("pattern"), "string.pattern") + descriptors.prefixSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("prefix"), "string.prefix") + descriptors.suffixSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("suffix"), "string.suffix") + descriptors.containsSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("contains"), "string.contains") + descriptors.notContainsSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_contains"), "string.not_contains") + descriptors.inSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("in"), "string.in") + descriptors.notInSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_in"), "string.not_in") + descriptors.wellKnownRegexSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("well_known_regex")) + return descriptors } //nolint:gochecknoglobals @@ -266,18 +251,20 @@ var ( // It bundles the field descriptor, rule IDs, messages, and validation // function so that all well-known checks share a single generic method. type stringWellKnownRule struct { - desc protoreflect.FieldDescriptor - ruleID string // e.g. "string.ip" - emptyRuleID string // e.g. "string.ip_empty"; empty means skip the empty check - mainMsg string // e.g. "must be a valid IP address" - emptyMsg string // e.g. "value is empty, which is not a valid IP address" + site ruleSite // pre-built rule path site for the error path + ruleID string // e.g. "string.ip" + emptyRuleID string // e.g. "string.ip_empty"; empty means skip the empty check + mainMsg string // e.g. "must be a valid IP address" + emptyMsg string // e.g. "value is empty, which is not a valid IP address" validate func(string) bool } //nolint:gochecknoglobals var ( + rulesDesc = (*validate.StringRules)(nil).ProtoReflect().Descriptor() + stringRuleEmail = stringWellKnownRule{ - desc: strDescs.emailDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("email")), ruleID: "string.email", emptyRuleID: "string.email_empty", mainMsg: "must be a valid email address", @@ -285,7 +272,7 @@ var ( validate: rules.IsEmail, } stringRuleHostname = stringWellKnownRule{ - desc: strDescs.hostNameDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("hostname")), ruleID: "string.hostname", emptyRuleID: "string.hostname_empty", mainMsg: "must be a valid hostname", @@ -293,7 +280,7 @@ var ( validate: rules.IsHostname, } stringRuleIP = stringWellKnownRule{ - desc: strDescs.ipDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip")), ruleID: "string.ip", emptyRuleID: "string.ip_empty", mainMsg: "must be a valid IP address", @@ -301,7 +288,7 @@ var ( validate: func(s string) bool { return rules.IsIP(s, 0) }, } stringRuleIPv4 = stringWellKnownRule{ - desc: strDescs.ipv4Desc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4")), ruleID: "string.ipv4", emptyRuleID: "string.ipv4_empty", mainMsg: "must be a valid IPv4 address", @@ -309,7 +296,7 @@ var ( validate: func(s string) bool { return rules.IsIP(s, 4) }, } stringRuleIPv6 = stringWellKnownRule{ - desc: strDescs.ipv6Desc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6")), ruleID: "string.ipv6", emptyRuleID: "string.ipv6_empty", mainMsg: "must be a valid IPv6 address", @@ -317,7 +304,7 @@ var ( validate: func(s string) bool { return rules.IsIP(s, 6) }, } stringRuleURI = stringWellKnownRule{ - desc: strDescs.uriDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uri")), ruleID: "string.uri", emptyRuleID: "string.uri_empty", mainMsg: "must be a valid URI", @@ -325,13 +312,13 @@ var ( validate: rules.IsURI, } stringRuleURIRef = stringWellKnownRule{ - desc: strDescs.uriRefDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uri_ref")), ruleID: "string.uri_ref", mainMsg: "must be a valid URI Reference", validate: rules.IsURIRef, } stringRuleAddress = stringWellKnownRule{ - desc: strDescs.addressDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("address")), ruleID: "string.address", emptyRuleID: "string.address_empty", mainMsg: "must be a valid hostname, or ip address", @@ -339,7 +326,7 @@ var ( validate: func(s string) bool { return rules.IsHostname(s) || rules.IsIP(s, 0) }, } stringRuleUUID = stringWellKnownRule{ - desc: strDescs.uuidDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uuid")), ruleID: "string.uuid", emptyRuleID: "string.uuid_empty", mainMsg: "must be a valid UUID", @@ -347,7 +334,7 @@ var ( validate: uuidRegexp.MatchString, } stringRuleTUUID = stringWellKnownRule{ - desc: strDescs.tuuidDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("tuuid")), ruleID: "string.tuuid", emptyRuleID: "string.tuuid_empty", mainMsg: "must be a valid trimmed UUID", @@ -355,7 +342,7 @@ var ( validate: tuuidRegexp.MatchString, } stringRuleIPPrefixLen = stringWellKnownRule{ - desc: strDescs.ipPrefixLenDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip_with_prefixlen")), ruleID: "string.ip_with_prefixlen", emptyRuleID: "string.ip_with_prefixlen_empty", mainMsg: "must be a valid IP prefix", @@ -363,7 +350,7 @@ var ( validate: func(s string) bool { return rules.IsIPPrefix(s, 0, false) }, } stringRuleIPv4PrefixLen = stringWellKnownRule{ - desc: strDescs.ipv4PrefixLenDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4_with_prefixlen")), ruleID: "string.ipv4_with_prefixlen", emptyRuleID: "string.ipv4_with_prefixlen_empty", mainMsg: "must be a valid IPv4 address with prefix length", @@ -371,7 +358,7 @@ var ( validate: func(s string) bool { return rules.IsIPPrefix(s, 4, false) }, } stringRuleIPv6PrefixLen = stringWellKnownRule{ - desc: strDescs.ipv6PrefixLenDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6_with_prefixlen")), ruleID: "string.ipv6_with_prefixlen", emptyRuleID: "string.ipv6_with_prefixlen_empty", mainMsg: "must be a valid IPv6 address with prefix length", @@ -379,7 +366,7 @@ var ( validate: func(s string) bool { return rules.IsIPPrefix(s, 6, false) }, } stringRuleIPPrefix = stringWellKnownRule{ - desc: strDescs.ipPrefixDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip_prefix")), ruleID: "string.ip_prefix", emptyRuleID: "string.ip_prefix_empty", mainMsg: "must be a valid IP prefix", @@ -387,7 +374,7 @@ var ( validate: func(s string) bool { return rules.IsIPPrefix(s, 0, true) }, } stringRuleIPv4Prefix = stringWellKnownRule{ - desc: strDescs.ipv4PrefixDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4_prefix")), ruleID: "string.ipv4_prefix", emptyRuleID: "string.ipv4_prefix_empty", mainMsg: "must be a valid IPv4 prefix", @@ -395,7 +382,7 @@ var ( validate: func(s string) bool { return rules.IsIPPrefix(s, 4, true) }, } stringRuleIPv6Prefix = stringWellKnownRule{ - desc: strDescs.ipv6PrefixDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6_prefix")), ruleID: "string.ipv6_prefix", emptyRuleID: "string.ipv6_prefix_empty", mainMsg: "must be a valid IPv6 prefix", @@ -403,7 +390,7 @@ var ( validate: func(s string) bool { return rules.IsIPPrefix(s, 6, true) }, } stringRuleHostAndPort = stringWellKnownRule{ - desc: strDescs.hostAndPortDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("host_and_port")), ruleID: "string.host_and_port", emptyRuleID: "string.host_and_port_empty", mainMsg: "must be a valid host (hostname or IP address) and port pair", @@ -411,7 +398,7 @@ var ( validate: func(s string) bool { return rules.IsHostAndPort(s, true) }, } stringRuleULID = stringWellKnownRule{ - desc: strDescs.ulidDesc, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ulid")), ruleID: "string.ulid", emptyRuleID: "string.ulid_empty", mainMsg: "must be a valid ULID", @@ -472,7 +459,7 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu } if n.constVal != nil && strVal != *n.constVal { - violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.constDesc, + violations = append(violations, n.newViolation(strDescs.constSite, "string.const", fmt.Sprintf("must equal `%s`", *n.constVal), val, protoreflect.ValueOfString(*n.constVal))) if cfg.failFast { @@ -481,7 +468,7 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu } if n.pattern != nil && !n.pattern.MatchString(strVal) { - violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.patternDesc, + violations = append(violations, n.newViolation(strDescs.patternSite, "string.pattern", fmt.Sprintf("does not match regex pattern `%s`", n.patternStr), val, protoreflect.ValueOfString(n.patternStr))) if cfg.failFast { @@ -490,7 +477,7 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu } if n.prefix != nil && !strings.HasPrefix(strVal, *n.prefix) { - violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.prefixDesc, + violations = append(violations, n.newViolation(strDescs.prefixSite, "string.prefix", fmt.Sprintf("does not have prefix `%s`", *n.prefix), val, protoreflect.ValueOfString(*n.prefix))) if cfg.failFast { @@ -499,7 +486,7 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu } if n.suffix != nil && !strings.HasSuffix(strVal, *n.suffix) { - violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.suffixDesc, + violations = append(violations, n.newViolation(strDescs.suffixSite, "string.suffix", fmt.Sprintf("does not have suffix `%s`", *n.suffix), val, protoreflect.ValueOfString(*n.suffix))) if cfg.failFast { @@ -508,7 +495,7 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu } if n.contains != nil && !strings.Contains(strVal, *n.contains) { - violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.containsDesc, + violations = append(violations, n.newViolation(strDescs.containsSite, "string.contains", fmt.Sprintf("does not contain substring `%s`", *n.contains), val, protoreflect.ValueOfString(*n.contains))) if cfg.failFast { @@ -517,7 +504,7 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu } if n.notContains != nil && strings.Contains(strVal, *n.notContains) { - violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.notContainsDesc, + violations = append(violations, n.newViolation(strDescs.notContainsSite, "string.not_contains", fmt.Sprintf("value contains substring `%s`", *n.notContains), val, protoreflect.ValueOfString(*n.notContains))) if cfg.failFast { @@ -526,7 +513,7 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu } if len(n.inVals) > 0 && !slices.Contains(n.inVals, strVal) { - violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.inDesc, + violations = append(violations, n.newViolation(strDescs.inSite, "string.in", "must be in list "+formatStringList(n.inVals), val, protoreflect.ValueOfString(strVal))) if cfg.failFast { @@ -535,7 +522,7 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu } if len(n.notInVals) > 0 && slices.Contains(n.notInVals, strVal) { - violations = append(violations, n.newViolation(strDescs.ruleDesc, strDescs.notInDesc, + violations = append(violations, n.newViolation(strDescs.notInSite, "string.not_in", "must not be in list "+formatStringList(n.notInVals), val, protoreflect.ValueOfString(strVal))) if cfg.failFast { @@ -571,12 +558,12 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu func (n nativeStringEval) checkWellKnown(strVal string, val protoreflect.Value) []*Violation { rule := n.wellKnownRule if rule.emptyRuleID != "" && strVal == "" { - return []*Violation{n.newViolation(strDescs.ruleDesc, rule.desc, + return []*Violation{n.newViolation(rule.site, rule.emptyRuleID, rule.emptyMsg, val, protoreflect.ValueOfString(strVal))} } if !rule.validate(strVal) { - return []*Violation{n.newViolation(strDescs.ruleDesc, rule.desc, + return []*Violation{n.newViolation(rule.site, rule.ruleID, rule.mainMsg, val, protoreflect.ValueOfString(strVal))} } @@ -595,7 +582,7 @@ func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) switch n.knownRegex { case validate.KnownRegex_KNOWN_REGEX_HTTP_HEADER_NAME: if strVal == "" { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.wellKnownRegexDesc, + return []*Violation{n.newViolation(strDescs.wellKnownRegexSite, "string.well_known_regex.header_name_empty", "value is empty, which is not a valid HTTP header name", val, protoreflect.ValueOfString(strVal))} } @@ -613,7 +600,7 @@ func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) matcher = looseRegexp } if !matcher.MatchString(strVal) { - return []*Violation{n.newViolation(strDescs.ruleDesc, strDescs.wellKnownRegexDesc, + return []*Violation{n.newViolation(strDescs.wellKnownRegexSite, rule, msg, val, protoreflect.ValueOfString(strVal))} } @@ -627,17 +614,17 @@ func (n nativeStringEval) checkKnownRegex(strVal string, val protoreflect.Value) func (n nativeStringEval) evaluateByteLength(byteCount uint64, val protoreflect.Value) []*Violation { var out []*Violation if n.exactBytes != nil && byteCount != *n.exactBytes { - out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.lenBytesDesc, + out = append(out, n.newViolation(strDescs.lenBytesSite, "string.len_bytes", fmt.Sprintf("must be %d bytes", *n.exactBytes), val, protoreflect.ValueOfUint64(*n.exactBytes))) } if n.minBytes != nil && byteCount < *n.minBytes { - out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.minBytesDesc, + out = append(out, n.newViolation(strDescs.minBytesSite, "string.min_bytes", fmt.Sprintf("must be at least %d bytes", *n.minBytes), val, protoreflect.ValueOfUint64(*n.minBytes))) } if n.maxBytes != nil && byteCount > *n.maxBytes { - out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.maxBytesDesc, + out = append(out, n.newViolation(strDescs.maxBytesSite, "string.max_bytes", fmt.Sprintf("must be at most %d bytes", *n.maxBytes), val, protoreflect.ValueOfUint64(*n.maxBytes))) } @@ -652,17 +639,17 @@ func (n nativeStringEval) evaluateLength(runeCount uint64, val protoreflect.Valu var out []*Violation if n.exactLen != nil && runeCount != *n.exactLen { - out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.lenDesc, + out = append(out, n.newViolation(strDescs.lenSite, "string.len", fmt.Sprintf("must be %d characters", *n.exactLen), val, protoreflect.ValueOfUint64(*n.exactLen))) } if n.minLen != nil && runeCount < *n.minLen { - out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.minLenDesc, + out = append(out, n.newViolation(strDescs.minLenSite, "string.min_len", fmt.Sprintf("must be at least %d characters", *n.minLen), val, protoreflect.ValueOfUint64(*n.minLen))) } if n.maxLen != nil && runeCount > *n.maxLen { - out = append(out, n.newViolation(strDescs.ruleDesc, strDescs.maxLenDesc, + out = append(out, n.newViolation(strDescs.maxLenSite, "string.max_len", fmt.Sprintf("must be at most %d characters", *n.maxLen), val, protoreflect.ValueOfUint64(*n.maxLen))) } diff --git a/proto/tests/example/v1/native_test.proto b/proto/tests/example/v1/native_test.proto index 69ef7dff..837ef9c5 100644 --- a/proto/tests/example/v1/native_test.proto +++ b/proto/tests/example/v1/native_test.proto @@ -199,3 +199,10 @@ message WrapperTesting { google.protobuf.StringValue s = 8 [(buf.validate.field).string.const = "hello"]; google.protobuf.BytesValue bs = 9 [(buf.validate.field).bytes.len = 5]; } + +message MultiRule { + int64 many = 1 [ + (buf.validate.field).int64.const = 10, + (buf.validate.field).int64.gt = 5 + ]; +} diff --git a/validator_bench_test.go b/validator_bench_test.go index 2b49cdc2..914811f2 100644 --- a/validator_bench_test.go +++ b/validator_bench_test.go @@ -192,6 +192,36 @@ func BenchmarkCompileInt32GT(b *testing.B) { } } +func BenchmarkMultiRuleError(b *testing.B) { + msg := pb.MultiRule_builder{Many: 1}.Build() + options := []ValidatorOption{WithMessages(msg), WithDisableLazy()} + if strings.EqualFold(os.Getenv("DISABLE_NATIVE_RULES"), "true") { + options = append(options, WithDisableNativeRules()) + } + val, err := New(options...) + require.NoError(b, err) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = val.Validate(msg) + } +} + +func BenchmarkMultiRuleNoError(b *testing.B) { + msg := pb.MultiRule_builder{Many: 10}.Build() + options := []ValidatorOption{WithMessages(msg), WithDisableLazy()} + if strings.EqualFold(os.Getenv("DISABLE_NATIVE_RULES"), "true") { + options = append(options, WithDisableNativeRules()) + } + val, err := New(options...) + require.NoError(b, err) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = val.Validate(msg) + } +} + func testSuccess(t *testing.T, msg proto.Message) { faker := protogofakeit.New(gofakeit.New(1)) require.NoError(t, faker.FakeProto(msg)) From e3e1585246a214140bf1d6f0f9ac85414296d903 Mon Sep 17 00:00:00 2001 From: Jon Bodner Date: Tue, 28 Apr 2026 14:27:39 -0400 Subject: [PATCH 14/14] address code review issues. Simplify MAKEFILE. Remove performance numbers from README.md, simplify the makeRuleSite functions. --- Makefile | 8 +- README.md | 79 +---------------- base.go | 34 +++----- native_bool.go | 4 +- native_bytes.go | 69 +++++++-------- native_enum.go | 6 +- native_map.go | 8 +- native_numeric.go | 16 ++-- native_repeated.go | 10 +-- native_string.go | 205 +++++++++++++++++---------------------------- 10 files changed, 138 insertions(+), 301 deletions(-) diff --git a/Makefile b/Makefile index 9457d969..5aed2981 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,6 @@ conformance: $(BIN)/protovalidate-conformance protovalidate-conformance-go ## Ru .PHONY: conformance-hyperpb conformance-hyperpb: ## Run conformance tests against hyperpb HYPERPB=true $(MAKE) conformance - HYPERPB=true DISABLE_NATIVE_RULES=true $(MAKE) conformance .PHONY: generate generate: generate-proto generate-license ## Regenerate code and license headers @@ -102,12 +101,7 @@ bench: $(BENCH_TMP) .PHONY: bench-cel bench-cel: $(BENCH_TMP) - DISABLE_NATIVE_RULES=true go test -run ^$$ -bench="$(BENCH)" -benchmem \ - -memprofile "$(BENCH_TMP)/$(BENCH_NAME).mem.profile" \ - -cpuprofile "$(BENCH_TMP)/$(BENCH_NAME).cpu.profile" \ - -count $(BENCH_COUNT) \ - | tee "$(BENCH_TMP)/$(BENCH_NAME).bench.txt" - + DISABLE_NATIVE_RULES=true $(MAKE) bench .PHONY: upgrade-go upgrade-go: diff --git a/README.md b/README.md index 576b161d..d0144454 100644 --- a/README.md +++ b/README.md @@ -66,86 +66,11 @@ Highlights for Go developers include: API documentation for Go is available on [pkg.go.dev][pkg-go]. ### Native standard validation rules -This release provides native support for standard validation rule processing. They are enabled by default -and can be disabled by setting the ValidatorOption `WithDisableNativeRules`. +protovalidate-go provides native support for standard validation rule processing. They are enabled by default and are disabled by setting the ValidatorOption `WithDisableNativeRules`. -We continue to validate that the native rules and the CEL rules produce identical results. -The `compliance` Makefile target has been updated to run twice, +We continue to validate that the native rules and the CEL rules produce identical results. The `compliance` Makefile target has been updated to run twice, once with the native rules enabled, and once with the CEL rules enabled. -Performance improvements on the included benchmarks: - -``` -goos: darwin -goarch: arm64 -pkg: buf.build/go/protovalidate -cpu: Apple M1 Max - │ .tmp/bench/2026-04-24:17:56:31.bench.txt │ .tmp/bench/2026-04-24:18:00:25.bench.txt │ - │ sec/op │ sec/op vs base │ -Scalar-10 165.90n ± 1% 68.80n ± 1% -58.53% (p=0.000 n=10) -Repeated/Scalar-10 279.80n ± 0% 95.84n ± 1% -65.75% (p=0.000 n=10) -Repeated/Message-10 685.5n ± 1% 288.8n ± 1% -57.86% (p=0.000 n=10) -Repeated/Unique/Scalar-10 1248.0n ± 1% 311.3n ± 0% -75.05% (p=0.000 n=10) -Repeated/Unique/Bytes-10 2491.0n ± 2% 448.3n ± 1% -82.00% (p=0.000 n=10) -Map-10 284.2n ± 1% 100.8n ± 0% -64.52% (p=0.000 n=10) -ComplexSchema-10 40.50µ ± 1% 13.47µ ± 0% -66.74% (p=0.000 n=10) -Int32GT-10 2744.5n ± 0% 774.6n ± 0% -71.78% (p=0.000 n=10) -TestByteMatching-10 1334.0n ± 1% 208.2n ± 1% -84.39% (p=0.000 n=10) -StringMatching-10 1893.0n ± 1% 906.5n ± 1% -52.11% (p=0.000 n=10) -WrapperTesting-10 3.044µ ± 1% 1.140µ ± 1% -62.57% (p=0.000 n=10) -Compile-10 8.137m ± 1% 1.983m ± 1% -75.63% (p=0.000 n=10) -CompileInt32GT-10 5.775m ± 1% 1.639m ± 2% -71.61% (p=0.000 n=10) -MultiRuleError-10 783.4n ± 1% 686.8n ± 2% -12.33% (p=0.000 n=10) -MultiRuleNoError-10 234.80n ± 1% 69.11n ± 0% -70.57% (p=0.000 n=10) -geomean 3.585µ 1.160µ -67.64% - - │ .tmp/bench/2026-04-24:17:56:31.bench.txt │ .tmp/bench/2026-04-24:18:00:25.bench.txt │ - │ B/op │ B/op vs base │ -Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -Repeated/Scalar-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) -Repeated/Message-10 120.00 ± 0% 48.00 ± 0% -60.00% (p=0.000 n=10) -Repeated/Unique/Scalar-10 536.0 ± 0% 132.0 ± 0% -75.37% (p=0.000 n=10) -Repeated/Unique/Bytes-10 1784.0 ± 0% 264.0 ± 0% -85.20% (p=0.000 n=10) -Map-10 128.00 ± 0% 64.00 ± 0% -50.00% (p=0.000 n=10) -ComplexSchema-10 10.552Ki ± 0% 4.250Ki ± 0% -59.72% (p=0.000 n=10) -Int32GT-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -TestByteMatching-10 408.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) -StringMatching-10 387.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) -WrapperTesting-10 192.0 ± 0% 0.0 ± 0% -100.00% (p=0.000 n=10) -Compile-10 6.826Mi ± 0% 2.039Mi ± 0% -70.13% (p=0.000 n=10) -CompileInt32GT-10 5.210Mi ± 0% 1.842Mi ± 0% -64.65% (p=0.000 n=10) -MultiRuleError-10 784.0 ± 0% 808.0 ± 0% +3.06% (p=0.000 n=10) -MultiRuleNoError-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -geomean ² ? ² ³ -¹ all samples are equal -² summaries must be >0 to compute geomean -³ ratios must be >0 to compute geomean - - │ .tmp/bench/2026-04-24:17:56:31.bench.txt │ .tmp/bench/2026-04-24:18:00:25.bench.txt │ - │ allocs/op │ allocs/op vs base │ -Scalar-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -Repeated/Scalar-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) -Repeated/Message-10 3.000 ± 0% 1.000 ± 0% -66.67% (p=0.000 n=10) -Repeated/Unique/Scalar-10 34.00 ± 0% 11.00 ± 0% -67.65% (p=0.000 n=10) -Repeated/Unique/Bytes-10 73.00 ± 0% 10.00 ± 0% -86.30% (p=0.000 n=10) -Map-10 2.000 ± 0% 1.000 ± 0% -50.00% (p=0.000 n=10) -ComplexSchema-10 419.0 ± 0% 121.0 ± 0% -71.12% (p=0.000 n=10) -Int32GT-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -TestByteMatching-10 17.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) -StringMatching-10 23.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) -WrapperTesting-10 17.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10) -Compile-10 96.37k ± 0% 21.58k ± 0% -77.61% (p=0.000 n=10) -CompileInt32GT-10 64.73k ± 0% 19.62k ± 0% -69.69% (p=0.000 n=10) -MultiRuleError-10 21.00 ± 0% 22.00 ± 0% +4.76% (p=0.000 n=10) -MultiRuleNoError-10 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=10) ¹ -geomean ² ? ² ³ -¹ all samples are equal -² summaries must be >0 to compute geomean -³ ratios must be >0 to compute geomean -``` - -Use `make bench` to generate benchmarks with native rules and `make bench-cel` to generate benchmarks with CEL rules. - ## Additional languages and repositories Protovalidate isn't just for Go! You might be interested in sibling repositories for other languages: diff --git a/base.go b/base.go index bcead0e3..fb64983e 100644 --- a/base.go +++ b/base.go @@ -82,38 +82,24 @@ type ruleSite struct { } // makeRuleSite pre-builds a ruleSite for a rule at compile time. -func makeRuleSite(ruleDesc, desc protoreflect.FieldDescriptor) ruleSite { - return ruleSite{ - pathElements: []*validate.FieldPathElement{ - fieldPathElement(ruleDesc), - fieldPathElement(desc), - }, - desc: desc, +// if the ruleID or message are NOT constant, pass in an empty string and supply it when calling newViolation. +func makeRuleSite(ruleDesc, desc protoreflect.FieldDescriptor, ruleID string, message string) ruleSite { + var ruleIDPtr *string + if ruleID != "" { + ruleIDPtr = proto.String(ruleID) } -} - -// makeRuleSiteWithID pre-builds a ruleSite for a rule at compile time. -func makeRuleSiteWithID(ruleDesc, desc protoreflect.FieldDescriptor, ruleID string) ruleSite { - return ruleSite{ - pathElements: []*validate.FieldPathElement{ - fieldPathElement(ruleDesc), - fieldPathElement(desc), - }, - desc: desc, - ruleID: proto.String(ruleID), + var messagePtr *string + if message != "" { + messagePtr = proto.String(message) } -} - -// makeRuleSiteWithID pre-builds a ruleSite for a rule at compile time. -func makeRuleSiteWithIDAndMessage(ruleDesc, desc protoreflect.FieldDescriptor, ruleID string, message string) ruleSite { return ruleSite{ pathElements: []*validate.FieldPathElement{ fieldPathElement(ruleDesc), fieldPathElement(desc), }, desc: desc, - ruleID: proto.String(ruleID), - message: proto.String(message), + ruleID: ruleIDPtr, + message: messagePtr, } } diff --git a/native_bool.go b/native_bool.go index 8db75039..d2c4e6b0 100644 --- a/native_bool.go +++ b/native_bool.go @@ -23,10 +23,10 @@ import ( //nolint:gochecknoglobals var ( - boolConstSite = makeRuleSiteWithID( + boolConstSite = makeRuleSite( fieldRulesDesc.Fields().ByName("bool"), (*validate.BoolRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const"), - "bool.const", + "bool.const", "", ) ) diff --git a/native_bytes.go b/native_bytes.go index e5e0d217..4d29d3f1 100644 --- a/native_bytes.go +++ b/native_bytes.go @@ -171,48 +171,35 @@ func tryBuildNativeBytesRules(base base, rules *validate.BytesRules) evaluator { // bytesWellKnown identifies which well-known bytes format constraint is active. type bytesWellKnown struct { - site ruleSite // pre-built rule path site for the error path - ruleID, emptyRuleID string - mainMsg, emptyMsg string - validSizes []int + site ruleSite // pre-built rule path site for the error path + emptySite ruleSite // pre-built rule path site for the empty value + validSizes []int } var ( //nolint:gochecknoglobals bytesWellKnownIP = bytesWellKnown{ - site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipDesc), - ruleID: "bytes.ip", - emptyRuleID: "bytes.ip_empty", - mainMsg: "must be a valid IP address", - emptyMsg: "value is empty, which is not a valid IP address", - validSizes: []int{4, 16}, + site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipDesc, "bytes.ip", "must be a valid IP address"), + emptySite: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipDesc, "bytes.ip_empty", "value is empty, which is not a valid IP address"), + validSizes: []int{4, 16}, } //nolint:gochecknoglobals bytesWellKnownIPv4 = bytesWellKnown{ - site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipv4Desc), - ruleID: "bytes.ipv4", - emptyRuleID: "bytes.ipv4_empty", - mainMsg: "must be a valid IPv4 address", - emptyMsg: "value is empty, which is not a valid IPv4 address", - validSizes: []int{4}, + site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipv4Desc, "bytes.ipv4", "must be a valid IPv4 address"), + emptySite: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipv4Desc, "bytes.ipv4_empty", "value is empty, which is not a valid IPv4 address"), + validSizes: []int{4}, } //nolint:gochecknoglobals bytesWellKnownIPv6 = bytesWellKnown{ - site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipv6Desc), - ruleID: "bytes.ipv6", - emptyRuleID: "bytes.ipv6_empty", - mainMsg: "must be a valid IPv6 address", - emptyMsg: "value is empty, which is not a valid IPv6 address", - validSizes: []int{16}, + site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipv6Desc, "bytes.ipv6", "must be a valid IPv6 address"), + emptySite: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.ipv6Desc, "bytes.ipv6_empty", "value is empty, which is not a valid IPv6 address"), + validSizes: []int{16}, } //nolint:gochecknoglobals bytesWellKnownUUID = bytesWellKnown{ - site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.uuidDesc), - ruleID: "bytes.uuid", - emptyRuleID: "bytes.uuid_empty", - mainMsg: "must be a valid UUID", - emptyMsg: "value is empty, which is not a valid UUID", - validSizes: []int{16}, + site: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.uuidDesc, "bytes.uuid", "must be a valid UUID"), + emptySite: makeRuleSite(bytesDescs.ruleDesc, bytesDescs.uuidDesc, "bytes.uuid_empty", "value is empty, which is not a valid UUID"), + validSizes: []int{16}, } ) @@ -246,16 +233,16 @@ func makeBytesDescriptors() bytesDescriptors { ipv6Desc: rulesDesc.Fields().ByName("ipv6"), uuidDesc: rulesDesc.Fields().ByName("uuid"), } - descriptors.constSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("const"), "bytes.const") - descriptors.lenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("len"), "bytes.len") - descriptors.minLenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_len"), "bytes.min_len") - descriptors.maxLenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_len"), "bytes.max_len") - descriptors.patternSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("pattern"), "bytes.pattern") - descriptors.prefixSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("prefix"), "bytes.prefix") - descriptors.suffixSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("suffix"), "bytes.suffix") - descriptors.containsSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("contains"), "bytes.contains") - descriptors.inSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("in"), "bytes.in") - descriptors.notInSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_in"), "bytes.not_in") + descriptors.constSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("const"), "bytes.const", "") + descriptors.lenSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("len"), "bytes.len", "") + descriptors.minLenSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_len"), "bytes.min_len", "") + descriptors.maxLenSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_len"), "bytes.max_len", "") + descriptors.patternSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("pattern"), "bytes.pattern", "") + descriptors.prefixSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("prefix"), "bytes.prefix", "") + descriptors.suffixSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("suffix"), "bytes.suffix", "") + descriptors.containsSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("contains"), "bytes.contains", "") + descriptors.inSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("in"), "bytes.in", "") + descriptors.notInSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_in"), "bytes.not_in", "") return descriptors } @@ -412,8 +399,8 @@ func (n nativeBytesEval) evaluateWellKnown(bytesVal []byte, val protoreflect.Val wellKnown := n.wellKnown if size == 0 { - return n.newViolation(wellKnown.site, - wellKnown.emptyRuleID, wellKnown.emptyMsg, + return n.newViolation(wellKnown.emptySite, + "", "", val, protoreflect.ValueOfBool(true)) } @@ -422,7 +409,7 @@ func (n nativeBytesEval) evaluateWellKnown(bytesVal []byte, val protoreflect.Val } return n.newViolation(wellKnown.site, - wellKnown.ruleID, wellKnown.mainMsg, + "", "", val, protoreflect.ValueOfBool(true)) } diff --git a/native_enum.go b/native_enum.go index 2e8224e0..8f315cad 100644 --- a/native_enum.go +++ b/native_enum.go @@ -24,9 +24,9 @@ import ( //nolint:gochecknoglobals var ( - enumConstSite = makeRuleSiteWithID(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const"), "enum.const") - enumInSite = makeRuleSiteWithID(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("in"), "enum.in") - enumNotInSite = makeRuleSiteWithID(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("not_in"), "enum.not_in") + enumConstSite = makeRuleSite(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("const"), "enum.const", "") + enumInSite = makeRuleSite(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("in"), "enum.in", "") + enumNotInSite = makeRuleSite(enumRuleDescriptor, (*validate.EnumRules)(nil).ProtoReflect().Descriptor().Fields().ByName("not_in"), "enum.not_in", "") ) // tryBuildNativeEnumRules attempts to build a native Go evaluator for diff --git a/native_map.go b/native_map.go index 2496be26..cb49d9de 100644 --- a/native_map.go +++ b/native_map.go @@ -24,15 +24,15 @@ import ( //nolint:gochecknoglobals var ( - mapMinPairsSite = makeRuleSiteWithID( + mapMinPairsSite = makeRuleSite( mapFieldRulesDesc, (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("min_pairs"), - "map.min_pairs", + "map.min_pairs", "", ) - mapMaxPairsSite = makeRuleSiteWithID( + mapMaxPairsSite = makeRuleSite( mapFieldRulesDesc, (*validate.MapRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_pairs"), - "map.max_pairs", + "map.max_pairs", "", ) ) diff --git a/native_numeric.go b/native_numeric.go index d9c5b2ad..9a2909e6 100644 --- a/native_numeric.go +++ b/native_numeric.go @@ -258,16 +258,16 @@ func makeNumericDescriptors( finiteDesc = rulesDesc.Fields().ByName("finite") } descriptors := numericDescriptors{ - gtSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("gt")), - gteSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("gte")), - ltSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("lt")), - lteSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("lte")), - constSite: makeRuleSiteWithID(ruleDesc, rulesDesc.Fields().ByName("const"), typeName+".const"), - inSite: makeRuleSiteWithID(ruleDesc, rulesDesc.Fields().ByName("in"), typeName+".in"), - notInSite: makeRuleSiteWithID(ruleDesc, rulesDesc.Fields().ByName("not_in"), typeName+".not_in"), + gtSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("gt"), "", ""), + gteSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("gte"), "", ""), + ltSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("lt"), "", ""), + lteSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("lte"), "", ""), + constSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("const"), typeName+".const", ""), + inSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("in"), typeName+".in", ""), + notInSite: makeRuleSite(ruleDesc, rulesDesc.Fields().ByName("not_in"), typeName+".not_in", ""), } if finiteDesc != nil { - descriptors.finiteSite = makeRuleSiteWithIDAndMessage(ruleDesc, finiteDesc, typeName+".finite", "must be finite") + descriptors.finiteSite = makeRuleSite(ruleDesc, finiteDesc, typeName+".finite", "must be finite") } return descriptors } diff --git a/native_repeated.go b/native_repeated.go index 1ace3669..6abf61f1 100644 --- a/native_repeated.go +++ b/native_repeated.go @@ -25,17 +25,17 @@ import ( //nolint:gochecknoglobals var ( - repeatedMinItemsSite = makeRuleSiteWithID( + repeatedMinItemsSite = makeRuleSite( repeatedFieldRulesDesc, (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("min_items"), - "repeated.min_items", + "repeated.min_items", "", ) - repeatedMaxItemsSite = makeRuleSiteWithID( + repeatedMaxItemsSite = makeRuleSite( repeatedFieldRulesDesc, (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("max_items"), - "repeated.max_items", + "repeated.max_items", "", ) - repeatedUniqueSite = makeRuleSiteWithIDAndMessage( + repeatedUniqueSite = makeRuleSite( repeatedFieldRulesDesc, (*validate.RepeatedRules)(nil).ProtoReflect().Descriptor().Fields().ByName("unique"), "repeated.unique", diff --git a/native_string.go b/native_string.go index d96b1045..13fe5cd8 100644 --- a/native_string.go +++ b/native_string.go @@ -217,21 +217,21 @@ func makeStringDescriptors() stringDescriptors { descriptors := stringDescriptors{ ruleDesc: fieldRulesDesc.Fields().ByName("string"), } - descriptors.constSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("const"), "string.const") - descriptors.lenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("len"), "string.len") - descriptors.minLenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_len"), "string.min_len") - descriptors.maxLenSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_len"), "string.max_len") - descriptors.lenBytesSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("len_bytes"), "string.len_bytes") - descriptors.minBytesSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_bytes"), "string.min_bytes") - descriptors.maxBytesSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_bytes"), "string.max_bytes") - descriptors.patternSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("pattern"), "string.pattern") - descriptors.prefixSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("prefix"), "string.prefix") - descriptors.suffixSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("suffix"), "string.suffix") - descriptors.containsSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("contains"), "string.contains") - descriptors.notContainsSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_contains"), "string.not_contains") - descriptors.inSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("in"), "string.in") - descriptors.notInSite = makeRuleSiteWithID(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_in"), "string.not_in") - descriptors.wellKnownRegexSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("well_known_regex")) + descriptors.constSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("const"), "string.const", "") + descriptors.lenSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("len"), "string.len", "") + descriptors.minLenSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_len"), "string.min_len", "") + descriptors.maxLenSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_len"), "string.max_len", "") + descriptors.lenBytesSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("len_bytes"), "string.len_bytes", "") + descriptors.minBytesSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("min_bytes"), "string.min_bytes", "") + descriptors.maxBytesSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("max_bytes"), "string.max_bytes", "") + descriptors.patternSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("pattern"), "string.pattern", "") + descriptors.prefixSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("prefix"), "string.prefix", "") + descriptors.suffixSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("suffix"), "string.suffix", "") + descriptors.containsSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("contains"), "string.contains", "") + descriptors.notContainsSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_contains"), "string.not_contains", "") + descriptors.inSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("in"), "string.in", "") + descriptors.notInSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("not_in"), "string.not_in", "") + descriptors.wellKnownRegexSite = makeRuleSite(descriptors.ruleDesc, rulesDesc.Fields().ByName("well_known_regex"), "", "") return descriptors } @@ -251,12 +251,9 @@ var ( // It bundles the field descriptor, rule IDs, messages, and validation // function so that all well-known checks share a single generic method. type stringWellKnownRule struct { - site ruleSite // pre-built rule path site for the error path - ruleID string // e.g. "string.ip" - emptyRuleID string // e.g. "string.ip_empty"; empty means skip the empty check - mainMsg string // e.g. "must be a valid IP address" - emptyMsg string // e.g. "value is empty, which is not a valid IP address" - validate func(string) bool + site ruleSite // pre-built rule path site for the error path + emptySite ruleSite // pre-built rule path site for the empty value check + validate func(string) bool } //nolint:gochecknoglobals @@ -264,146 +261,94 @@ var ( rulesDesc = (*validate.StringRules)(nil).ProtoReflect().Descriptor() stringRuleEmail = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("email")), - ruleID: "string.email", - emptyRuleID: "string.email_empty", - mainMsg: "must be a valid email address", - emptyMsg: "value is empty, which is not a valid email address", - validate: rules.IsEmail, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("email"), "string.email", "must be a valid email address"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("email"), "string.email_empty", "value is empty, which is not a valid email address"), + validate: rules.IsEmail, } stringRuleHostname = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("hostname")), - ruleID: "string.hostname", - emptyRuleID: "string.hostname_empty", - mainMsg: "must be a valid hostname", - emptyMsg: "value is empty, which is not a valid hostname", - validate: rules.IsHostname, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("hostname"), "string.hostname", "must be a valid hostname"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("hostname"), "string.hostname_empty", "value is empty, which is not a valid hostname"), + validate: rules.IsHostname, } stringRuleIP = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip")), - ruleID: "string.ip", - emptyRuleID: "string.ip_empty", - mainMsg: "must be a valid IP address", - emptyMsg: "value is empty, which is not a valid IP address", - validate: func(s string) bool { return rules.IsIP(s, 0) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip"), "string.ip", "must be a valid IP address"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip"), "string.ip_empty", "value is empty, which is not a valid IP address"), + validate: func(s string) bool { return rules.IsIP(s, 0) }, } stringRuleIPv4 = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4")), - ruleID: "string.ipv4", - emptyRuleID: "string.ipv4_empty", - mainMsg: "must be a valid IPv4 address", - emptyMsg: "value is empty, which is not a valid IPv4 address", - validate: func(s string) bool { return rules.IsIP(s, 4) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4"), "string.ipv4", "must be a valid IPv4 address"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4"), "string.ipv4_empty", "value is empty, which is not a valid IPv4 address"), + validate: func(s string) bool { return rules.IsIP(s, 4) }, } stringRuleIPv6 = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6")), - ruleID: "string.ipv6", - emptyRuleID: "string.ipv6_empty", - mainMsg: "must be a valid IPv6 address", - emptyMsg: "value is empty, which is not a valid IPv6 address", - validate: func(s string) bool { return rules.IsIP(s, 6) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6"), "string.ipv6", "must be a valid IPv6 address"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6"), "string.ipv6_empty", "value is empty, which is not a valid IPv6 address"), + validate: func(s string) bool { return rules.IsIP(s, 6) }, } stringRuleURI = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uri")), - ruleID: "string.uri", - emptyRuleID: "string.uri_empty", - mainMsg: "must be a valid URI", - emptyMsg: "value is empty, which is not a valid URI", - validate: rules.IsURI, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uri"), "string.uri", "must be a valid URI"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uri"), "string.uri_empty", "value is empty, which is not a valid URI"), + validate: rules.IsURI, } stringRuleURIRef = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uri_ref")), - ruleID: "string.uri_ref", - mainMsg: "must be a valid URI Reference", + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uri_ref"), "string.uri_ref", "must be a valid URI Reference"), + // emptySite is unused validate: rules.IsURIRef, } stringRuleAddress = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("address")), - ruleID: "string.address", - emptyRuleID: "string.address_empty", - mainMsg: "must be a valid hostname, or ip address", - emptyMsg: "value is empty, which is not a valid hostname, or ip address", - validate: func(s string) bool { return rules.IsHostname(s) || rules.IsIP(s, 0) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("address"), "string.address", "must be a valid hostname, or ip address"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("address"), "string.address_empty", "value is empty, which is not a valid hostname, or ip address"), + validate: func(s string) bool { return rules.IsHostname(s) || rules.IsIP(s, 0) }, } stringRuleUUID = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uuid")), - ruleID: "string.uuid", - emptyRuleID: "string.uuid_empty", - mainMsg: "must be a valid UUID", - emptyMsg: "value is empty, which is not a valid UUID", - validate: uuidRegexp.MatchString, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uuid"), "string.uuid", "must be a valid UUID"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("uuid"), "string.uuid_empty", "value is empty, which is not a valid UUID"), + validate: uuidRegexp.MatchString, } stringRuleTUUID = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("tuuid")), - ruleID: "string.tuuid", - emptyRuleID: "string.tuuid_empty", - mainMsg: "must be a valid trimmed UUID", - emptyMsg: "value is empty, which is not a valid trimmed UUID", - validate: tuuidRegexp.MatchString, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("tuuid"), "string.tuuid", "must be a valid trimmed UUID"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("tuuid"), "string.tuuid_empty", "value is empty, which is not a valid trimmed UUID"), + validate: tuuidRegexp.MatchString, } stringRuleIPPrefixLen = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip_with_prefixlen")), - ruleID: "string.ip_with_prefixlen", - emptyRuleID: "string.ip_with_prefixlen_empty", - mainMsg: "must be a valid IP prefix", - emptyMsg: "value is empty, which is not a valid IP prefix", - validate: func(s string) bool { return rules.IsIPPrefix(s, 0, false) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip_with_prefixlen"), "string.ip_with_prefixlen", "must be a valid IP prefix"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip_with_prefixlen"), "string.ip_with_prefixlen_empty", "value is empty, which is not a valid IP prefix"), + validate: func(s string) bool { return rules.IsIPPrefix(s, 0, false) }, } stringRuleIPv4PrefixLen = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4_with_prefixlen")), - ruleID: "string.ipv4_with_prefixlen", - emptyRuleID: "string.ipv4_with_prefixlen_empty", - mainMsg: "must be a valid IPv4 address with prefix length", - emptyMsg: "value is empty, which is not a valid IPv4 address with prefix length", - validate: func(s string) bool { return rules.IsIPPrefix(s, 4, false) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4_with_prefixlen"), "string.ipv4_with_prefixlen", "must be a valid IPv4 address with prefix length"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4_with_prefixlen"), "string.ipv4_with_prefixlen_empty", "value is empty, which is not a valid IPv4 address with prefix length"), + validate: func(s string) bool { return rules.IsIPPrefix(s, 4, false) }, } stringRuleIPv6PrefixLen = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6_with_prefixlen")), - ruleID: "string.ipv6_with_prefixlen", - emptyRuleID: "string.ipv6_with_prefixlen_empty", - mainMsg: "must be a valid IPv6 address with prefix length", - emptyMsg: "value is empty, which is not a valid IPv6 address with prefix length", - validate: func(s string) bool { return rules.IsIPPrefix(s, 6, false) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6_with_prefixlen"), "string.ipv6_with_prefixlen", "must be a valid IPv6 address with prefix length"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6_with_prefixlen"), "string.ipv6_with_prefixlen_empty", "value is empty, which is not a valid IPv6 address with prefix length"), + validate: func(s string) bool { return rules.IsIPPrefix(s, 6, false) }, } stringRuleIPPrefix = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip_prefix")), - ruleID: "string.ip_prefix", - emptyRuleID: "string.ip_prefix_empty", - mainMsg: "must be a valid IP prefix", - emptyMsg: "value is empty, which is not a valid IP prefix", - validate: func(s string) bool { return rules.IsIPPrefix(s, 0, true) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip_prefix"), "string.ip_prefix", "must be a valid IP prefix"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ip_prefix"), "string.ip_prefix_empty", "value is empty, which is not a valid IP prefix"), + validate: func(s string) bool { return rules.IsIPPrefix(s, 0, true) }, } stringRuleIPv4Prefix = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4_prefix")), - ruleID: "string.ipv4_prefix", - emptyRuleID: "string.ipv4_prefix_empty", - mainMsg: "must be a valid IPv4 prefix", - emptyMsg: "value is empty, which is not a valid IPv4 prefix", - validate: func(s string) bool { return rules.IsIPPrefix(s, 4, true) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4_prefix"), "string.ipv4_prefix", "must be a valid IPv4 prefix"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv4_prefix"), "string.ipv4_prefix_empty", "value is empty, which is not a valid IPv4 prefix"), + validate: func(s string) bool { return rules.IsIPPrefix(s, 4, true) }, } stringRuleIPv6Prefix = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6_prefix")), - ruleID: "string.ipv6_prefix", - emptyRuleID: "string.ipv6_prefix_empty", - mainMsg: "must be a valid IPv6 prefix", - emptyMsg: "value is empty, which is not a valid IPv6 prefix", - validate: func(s string) bool { return rules.IsIPPrefix(s, 6, true) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6_prefix"), "string.ipv6_prefix", "must be a valid IPv6 prefix"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ipv6_prefix"), "string.ipv6_prefix_empty", "value is empty, which is not a valid IPv6 prefix"), + validate: func(s string) bool { return rules.IsIPPrefix(s, 6, true) }, } stringRuleHostAndPort = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("host_and_port")), - ruleID: "string.host_and_port", - emptyRuleID: "string.host_and_port_empty", - mainMsg: "must be a valid host (hostname or IP address) and port pair", - emptyMsg: "value is empty, which is not a valid host and port pair", - validate: func(s string) bool { return rules.IsHostAndPort(s, true) }, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("host_and_port"), "string.host_and_port", "must be a valid host (hostname or IP address) and port pair"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("host_and_port"), "string.host_and_port_empty", "value is empty, which is not a valid host and port pair"), + validate: func(s string) bool { return rules.IsHostAndPort(s, true) }, } stringRuleULID = stringWellKnownRule{ - site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ulid")), - ruleID: "string.ulid", - emptyRuleID: "string.ulid_empty", - mainMsg: "must be a valid ULID", - emptyMsg: "value is empty, which is not a valid ULID", - validate: ulidRegexp.MatchString, + site: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ulid"), "string.ulid", "must be a valid ULID"), + emptySite: makeRuleSite(strDescs.ruleDesc, rulesDesc.Fields().ByName("ulid"), "string.ulid_empty", "value is empty, which is not a valid ULID"), + validate: ulidRegexp.MatchString, } ) @@ -557,14 +502,14 @@ func (n nativeStringEval) Evaluate(_ protoreflect.Message, val protoreflect.Valu func (n nativeStringEval) checkWellKnown(strVal string, val protoreflect.Value) []*Violation { rule := n.wellKnownRule - if rule.emptyRuleID != "" && strVal == "" { - return []*Violation{n.newViolation(rule.site, - rule.emptyRuleID, rule.emptyMsg, + if rule.emptySite.ruleID != nil && strVal == "" { + return []*Violation{n.newViolation(rule.emptySite, + "", "", val, protoreflect.ValueOfString(strVal))} } if !rule.validate(strVal) { return []*Violation{n.newViolation(rule.site, - rule.ruleID, rule.mainMsg, + "", "", val, protoreflect.ValueOfString(strVal))} } return nil