feat(lagoon): check min and max as length on every non-numeric field (quick-261104-fx1)

- min and max compare the characters of a string or a number's decimal
  form and the element count of a slice or map on any field without
  numeric or integer (Laravel's getSize), with the min.array and
  max.array messages for lists
- blank values, booleans, dates and byte slices are not measured; a
  malformed bound is an error on every non-numeric field
- reverse the 261104-f4x pin, prove it through the cabana settings save
  sequence, and update the README, docs and the storePassword comment
This commit is contained in:
Jakub Zych
2026-11-04 11:42:15 +01:00
parent 0785336a50
commit 335f8e608e
6 changed files with 177 additions and 50 deletions

View File

@@ -119,10 +119,11 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
return nil, fmt.Errorf("lagoon: unrecognized validation rule %q", tok)
}
}
// On a string or url field min and max are a length in characters. A
// malformed bound fails loudly whatever the value, so a typo in Rules()
// On a field that is not numeric or integer, min and max are a length
// (Laravel's getSize): characters of a string or number, items of a list.
// A malformed bound fails loudly whatever the value, so a typo in Rules()
// cannot disable the limit unnoticed.
lengthRange := (hasString || hasURL) && !hasNumeric && !hasInteger
lengthRange := !hasNumeric && !hasInteger
minLen, maxLen := -1, -1
if lengthRange {
var err error
@@ -148,16 +149,10 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
if maxArg != "" {
rangeMax = maxArg
}
} else {
if betweenMin != "" {
tags = append(tags, "min="+betweenMin, "max="+betweenMax)
}
if minArg != "" {
tags = append(tags, "min="+minArg)
}
if maxArg != "" {
tags = append(tags, "max="+maxArg)
}
} else if betweenMin != "" {
// The tags keep the between length check below running; the tags
// loop itself skips min and max.
tags = append(tags, "min="+betweenMin, "max="+betweenMax)
}
if numericRange && (rangeMin != "" || rangeMax != "") {
s, ok := numericString(val)
@@ -176,31 +171,30 @@ func validateField(ctx context.Context, tx *gorm.DB, model any, field, rule stri
if required && isEmptyValue(val) {
return []string{validateMessage(ctx, tr, "required", field, nil)}, nil
}
if hasString || hasURL {
// Like Laravel, string and url skip a blank value; required has
// already handled emptiness.
s, isStr := ruleString(val)
if !isEmptyValue(val) && (!isStr || strings.TrimSpace(s) != "") {
if hasString && !isStr {
return []string{validateMessage(ctx, tr, "string", field, nil)}, nil
}
var msgs []string
if hasURL && (!isStr || !urlPattern.MatchString(s)) {
msgs = append(msgs, validateMessage(ctx, tr, "url", field, nil))
}
if lengthRange && isStr {
n := utf8.RuneCountInString(s)
// Like Laravel, string, url, min and max skip a blank value; required
// has already handled emptiness.
s, isStr := ruleString(val)
if !isEmptyValue(val) && (!isStr || strings.TrimSpace(s) != "") {
if hasString && !isStr {
return []string{validateMessage(ctx, tr, "string", field, nil)}, nil
}
var msgs []string
if hasURL && (!isStr || !urlPattern.MatchString(s)) {
msgs = append(msgs, validateMessage(ctx, tr, "url", field, nil))
}
if lengthRange && (minLen >= 0 || maxLen >= 0) {
if n, kind, ok := ruleLength(val); ok {
params := map[string]string{"min": minArg, "max": maxArg}
if minLen >= 0 && n < minLen {
msgs = append(msgs, validateMessage(ctx, tr, "min.string", field, params))
msgs = append(msgs, validateMessage(ctx, tr, "min."+kind, field, params))
}
if maxLen >= 0 && n > maxLen {
msgs = append(msgs, validateMessage(ctx, tr, "max.string", field, params))
msgs = append(msgs, validateMessage(ctx, tr, "max."+kind, field, params))
}
}
if len(msgs) > 0 {
return msgs, nil
}
}
if len(msgs) > 0 {
return msgs, nil
}
}
usedBetween := betweenMin != "" && betweenMax != ""
@@ -408,6 +402,44 @@ func ruleString(val any) (string, bool) {
return rv.String(), true
}
// ruleLength is Laravel's getSize without a numeric rule: the characters
// (runes) of a string, a named string type or a number's decimal form, with
// kind "string", and the element count of a slice or map, with kind "array".
// Pointers and interfaces are dereferenced (a nil one is not measured).
// Booleans, structs (time.Time, Date, TimeOfDay), fixed-size arrays and byte
// slices are not measured: ok is false.
func ruleLength(val any) (n int, kind string, ok bool) {
if val == nil {
return 0, "", false
}
rv := reflect.ValueOf(val)
for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
if rv.IsNil() {
return 0, "", false
}
rv = rv.Elem()
}
switch rv.Kind() {
case reflect.String:
return utf8.RuneCountInString(rv.String()), "string", true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return utf8.RuneCountInString(strconv.FormatInt(rv.Int(), 10)), "string", true
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
return utf8.RuneCountInString(strconv.FormatUint(rv.Uint(), 10)), "string", true
case reflect.Float32, reflect.Float64:
return utf8.RuneCountInString(strconv.FormatFloat(rv.Float(), 'f', -1, 64)), "string", true
case reflect.Slice:
if rv.Type().Elem().Kind() == reflect.Uint8 {
return 0, "", false
}
return rv.Len(), "array", true
case reflect.Map:
return rv.Len(), "array", true
default:
return 0, "", false
}
}
func numericString(val any) (string, bool) {
if val == nil {
return "", false
@@ -509,7 +541,7 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
params["attribute"] = laravelAttribute(field)
key := "lagoon::validate." + rule
switch rule {
case "between.numeric", "min.string", "max.string":
case "between.numeric", "min.string", "max.string", "min.array", "max.array":
key = "lagoon::validation." + rule
}
if tr != nil {
@@ -556,6 +588,10 @@ func validateMessage(ctx context.Context, tr *phrasebook.Translator, rule, field
return "The " + attr + " must be at least " + params["min"] + " characters."
case "max.string":
return "The " + attr + " may not be greater than " + params["max"] + " characters."
case "min.array":
return "The " + attr + " must have at least " + params["min"] + " items."
case "max.array":
return "The " + attr + " may not have more than " + params["max"] + " items."
default:
return "The " + attr + " is invalid."
}