diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/limits.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/limits.go index b6db5e08cbe..ce91a2a8624 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/limits.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/limits.go @@ -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 } diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/limits_test.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/limits_test.go index f17937c6913..44e866ad8db 100644 --- a/staging/src/k8s.io/apimachinery/pkg/api/validate/limits_test.go +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/limits_test.go @@ -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) + }) + } +}