validate/limits: update MaxLength validation to be character-based

so that it aligns with the OpenAPI maxLength
validation evaluation process that performs
rune counting instead of a byte-based length check

Signed-off-by: Bryce Palmer <bpalmer@redhat.com>
This commit is contained in:
Bryce Palmer
2026-02-27 09:48:50 -05:00
parent 400743bdc3
commit b5dd643c95
2 changed files with 163 additions and 5 deletions

View File

@@ -18,6 +18,8 @@ package validate
import (
"context"
"math"
"unicode/utf8"
"k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/api/validate/constraints"
@@ -31,9 +33,40 @@ func MaxLength[T ~string](_ context.Context, _ operation.Operation, fldPath *fie
if value == nil {
return nil
}
if len(*value) > max {
return field.ErrorList{field.TooLong(fldPath, *value, max).WithOrigin("maxLength")}
// if the length of the value in bytes is less
// than the maximum size then we can confidently
// say that this value is within the bounds
// enforced by the maximum value regardless
// of the actual makeup of characters in the value
byteLength := len(*value)
if byteLength <= max {
return nil
}
// because runes are up to 4 byte characters, if we assume all characters
// in the input are runes, the minimum number of characters that
// are specified is len(value)/4. If the minimum multi-byte
// character count is greater than our enforced maximum, we
// can confidently say that the value is invalid without having
// to actually perform the more expensive rune counting step
minimum := int(math.Ceil(float64(byteLength) / 4.0))
if minimum > max || utf8.RuneCountInString(string(*value)) > max {
return field.ErrorList{field.TooLongCharacters(fldPath, *value, max).WithOrigin("maxLength")}
}
return nil
}
// MaxBytes verifies that the specified value is not longer than max bytes.
func MaxBytes[T ~string](_ context.Context, _ operation.Operation, fldPath *field.Path, value, _ *T, max int) field.ErrorList {
if value == nil {
return nil
}
if len(*value) > max {
return field.ErrorList{field.TooLong(fldPath, *value, max).WithOrigin("maxBytes")}
}
return nil
}

View File

@@ -42,7 +42,7 @@ func TestMaxLength(t *testing.T) {
value: "0",
max: 0,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), nil, 0).WithOrigin("maxLength"),
field.TooLongCharacters(field.NewPath("fldpath"), "", 0).WithOrigin("maxLength"),
},
}, {
name: "one character",
@@ -54,14 +54,55 @@ func TestMaxLength(t *testing.T) {
value: "01",
max: 1,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), nil, 1).WithOrigin("maxLength"),
field.TooLongCharacters(field.NewPath("fldpath"), "", 1).WithOrigin("maxLength"),
},
}, {
value: "",
max: -1,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), nil, -1).WithOrigin("maxLength"),
field.TooLongCharacters(field.NewPath("fldpath"), "", -1).WithOrigin("maxLength"),
},
}, {
name: "ascii-only characters, less characters than max (n-1)",
value: "abcdefghi",
max: 10,
wantErrs: nil,
}, {
name: "multi-byte characters, less characters than max (n-1)",
value: "©®©®©®©®©",
max: 10,
wantErrs: nil,
}, {
name: "ascii-only characters, more characters than max (n+1)",
value: "abcdefghijkl",
max: 10,
wantErrs: field.ErrorList{
field.TooLongCharacters(field.NewPath("fldpath"), "", 10).WithOrigin("maxLength"),
},
}, {
name: "multi-byte characters, more characters than max (n+1)",
value: "©®©®©®©®©®©",
max: 10,
wantErrs: field.ErrorList{
field.TooLongCharacters(field.NewPath("fldpath"), "", 10).WithOrigin("maxLength"),
},
}, {
name: "mixture of characters, minimum possible size of input is less than max, rune count exceed maximum",
value: "©abc®defghi",
max: 10,
wantErrs: field.ErrorList{
field.TooLongCharacters(field.NewPath("fldpath"), "", 10).WithOrigin("maxLength"),
},
}, {
name: "multi-byte characters, exact characters as max (n)",
value: "©®©®©®©®©®",
max: 10,
wantErrs: nil,
}, {
name: "ascii-only characters, exact characters as max (n)",
value: "abcdefghij",
max: 10,
wantErrs: nil,
}}
matcher := field.ErrorMatcher{}.ByOrigin().ByDetailSubstring().ByField().ByType()
@@ -213,3 +254,87 @@ func doTestMinimum[T constraints.Integer](t *testing.T, cases []minimumTestCase[
}
}
}
func TestMaxBytes(t *testing.T) {
cases := []struct {
name string
value string
max int
wantErrs field.ErrorList // regex
}{{
name: "empty string",
value: "",
max: 0,
wantErrs: nil,
}, {
name: "zero length",
value: "0",
max: 0,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), "", 0).WithOrigin("maxBytes"),
},
}, {
name: "one character",
value: "0",
max: 1,
wantErrs: nil,
}, {
name: "two characters",
value: "01",
max: 1,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), "", 1).WithOrigin("maxBytes"),
},
}, {
value: "",
max: -1,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), "", -1).WithOrigin("maxBytes"),
},
}, {
name: "ascii-only characters, less bytes than max",
value: "abcdefghi",
max: 10,
wantErrs: nil,
}, {
name: "multi-byte characters, less bytes than max",
value: "©®©®",
max: 10,
wantErrs: nil,
}, {
name: "ascii-only characters, more bytes than max",
value: "abcdefghijkl",
max: 10,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), "", 10).WithOrigin("maxBytes"),
},
}, {
name: "multi-byte characters, more bytes than max",
value: "©®©®©©",
max: 10,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), "", 10).WithOrigin("maxBytes"),
},
}, {
name: "mixture of characters, less bytes than max",
value: "©abc®®",
max: 10,
wantErrs: nil,
}, {
name: "mixture of characters, more bytes than max",
value: "©abc®®abc",
max: 10,
wantErrs: field.ErrorList{
field.TooLong(field.NewPath("fldpath"), "", 10).WithOrigin("maxBytes"),
},
}}
matcher := field.ErrorMatcher{}.ByOrigin().ByDetailSubstring().ByField().ByType()
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v := tc.value
gotErrs := MaxBytes(context.Background(), operation.Operation{}, field.NewPath("fldpath"), &v, nil, tc.max)
matcher.Test(t, tc.wantErrs, gotErrs)
})
}
}