Files
summercms/modules/lagoon/validate_rules.go
Jakub Zych 36983bc87e fix(12-05): match Laravel's in and not_in rules on array values
A 162-case truth table recorded from WinterCMS's validator (the vendored
winter/storm Factory, Laravel 9) found three divergences, all on arrays:

- in compared array elements loosely; Laravel uses array_diff, an exact
  string comparison, so ["1.0"] is not in 1,2;
- not_in failed when any element was listed; Laravel's validateNotIn is
  !validateIn, so an array passes unless every element is listed;
- not_in failed an array without the array rule; Laravel passes it.

validate_rules_test.go keeps the whole table (accepted, array keys,
boolean, numeric, integer, in/not_in, sizes by type, regex, dates and
comparisons, url, presence, nested and map wildcards, bail and order).
2026-10-02 15:42:47 +02:00

1216 lines
35 KiB
Go

package lagoon
import (
"encoding/csv"
"encoding/json"
"fmt"
"io"
"math"
"math/big"
"net"
"net/http"
"reflect"
"regexp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
)
// implicitRuleNames run even when the attribute is absent or blank, and a
// failure of one stops the attribute (Laravel's implicitRules).
var implicitRuleNames = []string{"required", "present", "filled", "accepted"}
// numericRuleNames make the size rules compare the value as a number.
var numericRuleNames = []string{"numeric", "integer"}
var sizeRuleNames = map[string]bool{"size": true, "between": true, "min": true, "max": true}
// imageExtensions is Laravel's image rule: mimes:jpg,jpeg,png,gif,bmp,svg,webp.
var imageExtensions = []string{"jpg", "jpeg", "png", "gif", "bmp", "svg", "webp"}
// requestRuleArity lists every rule ValidateRequest implements with its
// parameter count: -1 any number (at least one), 0 none.
var requestRuleArity = map[string]int{
"required": 0, "present": 0, "filled": 0, "accepted": 0,
"nullable": 0, "sometimes": 0, "bail": 0,
"array": -2, "string": 0, "integer": 0, "numeric": 0, "boolean": 0,
"email": 0, "url": 0, "date": 0,
"after": 1, "after_or_equal": 1, "before": 1, "before_or_equal": 1,
"exists": -1, "regex": 1, "not_regex": 1,
"in": -1, "not_in": -1, "mimes": -1, "image": 0, "file": 0,
"min": 1, "max": 1, "size": 1, "between": 2,
}
func isImplicitName(name string) bool {
for _, n := range implicitRuleNames {
if n == name {
return true
}
}
return false
}
func (r Rule) isImplicit() bool {
return r.custom == nil && isImplicitName(r.name)
}
type compiledRegex struct {
re *regexp.Regexp
}
// ParseRules parses a Laravel rule string such as "required|string|max:255"
// into rules. Parameters are split like PHP's str_getcsv (`in:LP,"EP 7"""`);
// a regex: or not_regex: parameter is kept whole, pipes and commas included,
// up to its closing PCRE delimiter. ParseRules is meant for rule tables built
// at package initialization: an unknown rule, a wrong parameter count, an
// unsafe exists: identifier or a pattern Go's RE2 cannot compile panics, so a
// broken table fails at boot and never at request time.
func ParseRules(spec string) []Rule {
var out []Rule
for _, tok := range splitRuleSpec(spec) {
out = append(out, mustParseRule(tok))
}
return out
}
func splitRuleSpec(spec string) []string {
var out []string
rest := spec
for rest != "" {
trimmed := strings.TrimLeft(rest, " \t")
if strings.HasPrefix(trimmed, "regex:") || strings.HasPrefix(trimmed, "not_regex:") {
name, pat, _ := strings.Cut(trimmed, ":")
end := regexTokenEnd(pat)
out = append(out, name+":"+pat[:end])
rest = pat[end:]
rest = strings.TrimPrefix(rest, "|")
continue
}
tok, after, found := strings.Cut(rest, "|")
if t := strings.TrimSpace(tok); t != "" {
out = append(out, t)
}
if !found {
break
}
rest = after
}
return out
}
// regexTokenEnd finds where a PCRE literal ends inside a rule string: at the
// first unescaped closing delimiter that is followed by modifiers and then a
// pipe or the end of the string.
func regexTokenEnd(pat string) int {
if pat == "" {
return 0
}
open, size := utf8.DecodeRuneInString(pat)
closing := closingDelimiter(open)
for i := size; i < len(pat); i++ {
c := pat[i]
if c == '\\' {
i++
continue
}
if rune(c) != closing {
continue
}
j := i + 1
for j < len(pat) && isPCREModifier(pat[j]) {
j++
}
if j == len(pat) || pat[j] == '|' {
return j
}
}
if k := strings.Index(pat, "|"); k >= 0 {
return k
}
return len(pat)
}
func closingDelimiter(open rune) rune {
switch open {
case '(':
return ')'
case '[':
return ']'
case '{':
return '}'
case '<':
return '>'
}
return open
}
func isPCREModifier(c byte) bool {
return strings.IndexByte("imsxuADSUXJn", c) >= 0
}
func mustParseRule(tok string) Rule {
name, param, hasParam := strings.Cut(tok, ":")
name = strings.ToLower(strings.TrimSpace(name))
switch name {
case "int":
name = "integer"
case "bool":
name = "boolean"
}
arity, known := requestRuleArity[name]
if !known {
panic(fmt.Sprintf("lagoon: ParseRules: unsupported rule %q", tok))
}
var args []string
if hasParam {
if name == "regex" || name == "not_regex" {
args = []string{param}
} else {
args = phpGetCSV(param)
}
}
switch {
case arity == 0 && len(args) > 0:
panic(fmt.Sprintf("lagoon: ParseRules: rule %q takes no parameters", tok))
case arity > 0 && len(args) != arity:
panic(fmt.Sprintf("lagoon: ParseRules: rule %q needs %d parameter(s)", tok, arity))
case arity == -1 && len(args) == 0:
panic(fmt.Sprintf("lagoon: ParseRules: rule %q needs parameters", tok))
}
r := Rule{name: name, args: args}
switch name {
case "min", "max", "size", "between":
for _, a := range args {
if _, ok := new(big.Rat).SetString(strings.TrimSpace(a)); !ok {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q has a non-numeric parameter", tok))
}
}
case "regex", "not_regex":
re, err := compilePCRE(args[0])
if err != nil {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q: %v", tok, err))
}
r.re = &compiledRegex{re: re}
case "exists":
if len(args) > 2 {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q: extra where clauses are not supported", tok))
}
table := args[0]
if i := strings.LastIndex(table, "."); i >= 0 {
table = table[i+1:]
}
if !identName.MatchString(table) {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q: unsafe table name", tok))
}
r.args[0] = table
if len(args) == 2 && args[1] != "NULL" && !identName.MatchString(args[1]) {
panic(fmt.Sprintf("lagoon: ParseRules: rule %q: unsafe column name", tok))
}
}
return r
}
// phpGetCSV splits a rule parameter list like PHP's str_getcsv: commas
// separate fields, a field may be double-quoted with "" as an escaped quote,
// and a quote inside an unquoted field is kept.
func phpGetCSV(s string) []string {
r := csv.NewReader(strings.NewReader(s))
r.LazyQuotes = true
r.FieldsPerRecord = -1
rec, err := r.Read()
if err != nil {
return []string{s}
}
return rec
}
// compilePCRE turns a PCRE literal (/pattern/flags) into a Go regexp. The i,
// m, s, u and D modifiers are supported (u is implied, D is Go's default end
// anchoring); any other modifier is an error.
func compilePCRE(lit string) (*regexp.Regexp, error) {
if len(lit) < 2 {
return nil, fmt.Errorf("pattern %q has no delimiters", lit)
}
open, size := utf8.DecodeRuneInString(lit)
if open == '\\' || open == utf8.RuneError || (open < 128 && (isAlnumByte(byte(open)) || open == ' ')) {
return nil, fmt.Errorf("pattern %q has an invalid delimiter", lit)
}
closing := closingDelimiter(open)
end := strings.LastIndex(lit, string(closing))
if end < size {
return nil, fmt.Errorf("pattern %q has no closing delimiter", lit)
}
body := lit[size:end]
flags := ""
for _, m := range lit[end+1:] {
switch m {
case 'i', 'm', 's':
if !strings.ContainsRune(flags, m) {
flags += string(m)
}
case 'u', 'D':
default:
return nil, fmt.Errorf("pattern %q uses the unsupported modifier %q", lit, m)
}
}
if open == closing {
body = strings.ReplaceAll(body, `\`+string(open), string(open))
}
if flags != "" {
body = "(?" + flags + ")" + body
}
return regexp.Compile(body)
}
func isAlnumByte(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
}
func (v *requestValidator) passes(rule Rule, attr string, value any, present bool) (bool, error) {
switch rule.name {
case "required":
return validateRequired(value), nil
case "present":
return present, nil
case "filled":
return !present || validateRequired(value), nil
case "accepted":
return validateRequired(value) && isAccepted(value), nil
case "nullable", "sometimes", "bail":
return true, nil
case "array":
return validateArray(value, rule.args), nil
case "string":
_, ok := value.(string)
return ok, nil
case "integer":
return filterInt(value), nil
case "numeric":
return isNumeric(value), nil
case "boolean":
return isStrictBoolean(value), nil
case "email":
s, ok := value.(string)
return ok && filterEmail(s), nil
case "url":
s, ok := value.(string)
return ok && urlPattern.MatchString(s), nil
case "date":
return validateDate(value), nil
case "after":
return v.compareDates(value, rule.args[0], ">"), nil
case "after_or_equal":
return v.compareDates(value, rule.args[0], ">="), nil
case "before":
return v.compareDates(value, rule.args[0], "<"), nil
case "before_or_equal":
return v.compareDates(value, rule.args[0], "<="), nil
case "exists":
return v.exists(attr, rule, value)
case "regex", "not_regex":
s, ok := regexSubject(value)
if !ok {
return false, nil
}
matched := rule.re.re.MatchString(s)
if rule.name == "regex" {
return matched, nil
}
return !matched, nil
case "in":
return v.validateIn(attr, value, rule.args), nil
case "not_in":
return v.validateNotIn(attr, value, rule.args), nil
case "file":
_, ok := asUploadedFile(value)
return ok, nil
case "image":
return validateMimes(value, imageExtensions), nil
case "mimes":
return validateMimes(value, rule.args), nil
case "min", "max", "size", "between":
size, ok := v.size(attr, value)
if !ok {
return false, nil
}
return sizeInRange(rule, size), nil
}
return false, fmt.Errorf("lagoon: rule %q is not implemented", rule.name)
}
func sizeInRange(rule Rule, size *big.Rat) bool {
bound := func(i int) *big.Rat {
r, _ := new(big.Rat).SetString(strings.TrimSpace(rule.args[i]))
return r
}
switch rule.name {
case "min":
return size.Cmp(bound(0)) >= 0
case "max":
return size.Cmp(bound(0)) <= 0
case "size":
return size.Cmp(bound(0)) == 0
default: // between
return size.Cmp(bound(0)) >= 0 && size.Cmp(bound(1)) <= 0
}
}
// size is Laravel's getSize: the number itself under a numeric rule, the
// element count of an array, kilobytes of a file, else the length of the
// string form in characters (PHP mb_strlen).
func (v *requestValidator) size(attr string, value any) (*big.Rat, bool) {
if isNumeric(value) && v.hasRule(attr, numericRuleNames...) {
s, _ := phpScalarString(value)
r, ok := new(big.Rat).SetString(phpTrim(s))
return r, ok
}
if n, ok := arrayLen(value); ok {
return new(big.Rat).SetInt64(int64(n)), true
}
if f, ok := asUploadedFile(value); ok {
return new(big.Rat).SetFrac64(f.Size, 1024), true
}
if value == nil {
return new(big.Rat), true
}
s, ok := phpScalarString(value)
if !ok {
return nil, false
}
return new(big.Rat).SetInt64(int64(utf8.RuneCountInString(s))), true
}
func validateRequired(value any) bool {
switch t := value.(type) {
case nil:
return false
case string:
return phpTrim(t) != ""
}
if n, ok := arrayLen(value); ok {
return n > 0
}
if f, ok := asUploadedFile(value); ok {
return f.Filename != "" || f.Size > 0
}
return true
}
func isAccepted(value any) bool {
switch t := value.(type) {
case bool:
return t
case string:
return t == "yes" || t == "on" || t == "1" || t == "true"
case json.Number:
return string(t) == "1"
case float64:
return t == 1
case int:
return t == 1
case int64:
return t == 1
}
return false
}
func validateArray(value any, keys []string) bool {
if _, ok := arrayLen(value); !ok {
return false
}
if len(keys) == 0 {
return true
}
allowed := map[string]bool{}
for _, k := range keys {
allowed[k] = true
}
for _, ch := range children(value) {
if !allowed[ch.key] {
return false
}
}
return true
}
func arrayLen(value any) (int, bool) {
switch t := value.(type) {
case []any:
return len(t), true
case map[string]any:
return len(t), true
case []string:
return len(t), true
case []map[string]any:
return len(t), true
}
rv := reflect.ValueOf(value)
if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Map {
return rv.Len(), true
}
return 0, false
}
func isStrictBoolean(value any) bool {
switch t := value.(type) {
case bool:
return true
case string:
return t == "0" || t == "1"
case json.Number:
return string(t) == "0" || string(t) == "1"
case float64:
return t == 0 || t == 1
case int:
return t == 0 || t == 1
case int64:
return t == 0 || t == 1
}
return false
}
// isNumeric ports PHP's is_numeric: numbers, and strings holding a decimal
// or exponent number with optional surrounding whitespace.
func isNumeric(value any) bool {
switch t := value.(type) {
case string:
return isNumericString(t)
case json.Number:
return isNumericString(string(t))
case float32:
return true
case float64:
return true
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return true
}
return false
}
func isNumericString(s string) bool {
s = strings.TrimLeft(s, " \t\n\r\v\f")
s = strings.TrimRight(s, " \t\n\r\v\f")
if s == "" {
return false
}
i := 0
if s[i] == '+' || s[i] == '-' {
i++
}
digits := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
digits++
}
if i < len(s) && s[i] == '.' {
i++
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
digits++
}
}
if digits == 0 {
return false
}
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
i++
if i < len(s) && (s[i] == '+' || s[i] == '-') {
i++
}
exp := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
exp++
}
if exp == 0 {
return false
}
}
return i == len(s)
}
// filterInt ports filter_var($value, FILTER_VALIDATE_INT) !== false: the
// string form, trimmed, must be an optionally signed decimal integer with no
// leading zero that fits in 64 bits. true counts as 1.
func filterInt(value any) bool {
var s string
switch t := value.(type) {
case bool:
return t
case nil:
return false
case int, int8, int16, int32, int64, uint8, uint16, uint32:
return true
case uint:
return uint64(t) <= math.MaxInt64
case uint64:
return t <= math.MaxInt64
case string:
s = t
case json.Number:
s = string(t)
if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") {
s = phpFloatString(f)
}
case float32:
s = phpFloatString(float64(t))
case float64:
s = phpFloatString(t)
default:
return false
}
s = strings.Trim(s, " \t\r\n\v\x00")
if s == "" {
return false
}
body := s
if body[0] == '+' || body[0] == '-' {
body = body[1:]
}
if body == "" {
return false
}
for i := 0; i < len(body); i++ {
if body[i] < '0' || body[i] > '9' {
return false
}
}
if len(body) > 1 && body[0] == '0' {
return false
}
_, err := strconv.ParseInt(s, 10, 64)
return err == nil
}
// phpScalarString is PHP's (string) cast for scalars.
func phpScalarString(value any) (string, bool) {
switch t := value.(type) {
case nil:
return "", true
case string:
return t, true
case json.Number:
if _, err := strconv.ParseInt(string(t), 10, 64); err == nil {
return string(t), true
}
if f, err := strconv.ParseFloat(string(t), 64); err == nil {
return phpFloatString(f), true
}
return string(t), true
case bool:
if t {
return "1", true
}
return "", true
case float32:
return phpFloatString(float64(t)), true
case float64:
return phpFloatString(t), true
case int:
return strconv.Itoa(t), true
case int8, int16, int32, int64:
return strconv.FormatInt(reflect.ValueOf(t).Int(), 10), true
case uint, uint8, uint16, uint32, uint64:
return strconv.FormatUint(reflect.ValueOf(t).Uint(), 10), true
}
return "", false
}
// phpFloatString formats a float as PHP 8 casts it to string: the shortest
// round-trip digits, in exponent form (1.0E+15) from 1e15 up and below 1e-4.
func phpFloatString(f float64) string {
switch {
case math.IsNaN(f):
return "NAN"
case math.IsInf(f, 1):
return "INF"
case math.IsInf(f, -1):
return "-INF"
case f == 0:
if math.Signbit(f) {
return "-0"
}
return "0"
}
e := strconv.FormatFloat(f, 'e', -1, 64)
mant, expStr, _ := strings.Cut(e, "e")
exp, _ := strconv.Atoi(expStr)
if exp >= -4 && exp < 15 {
return strconv.FormatFloat(f, 'f', -1, 64)
}
if !strings.Contains(mant, ".") {
mant += ".0"
}
sign := "+"
if exp < 0 {
sign = "-"
exp = -exp
}
return mant + "E" + sign + strconv.Itoa(exp)
}
// phpTrim trims the characters PHP's trim() does.
func phpTrim(s string) string {
return strings.Trim(s, " \t\n\r\x00\x0B")
}
func regexSubject(value any) (string, bool) {
if s, ok := value.(string); ok {
return s, true
}
if isNumeric(value) {
return phpScalarString(value)
}
return "", false
}
func asUploadedFile(value any) (UploadedFile, bool) {
switch t := value.(type) {
case UploadedFile:
return t, true
case *UploadedFile:
if t != nil {
return *t, true
}
}
return UploadedFile{}, false
}
// in compares like PHP's in_array((string) $value, $parameters): two numeric
// strings compare as numbers, anything else as bytes.
func phpLooseStringEqual(a, b string) bool {
if a == b {
return true
}
if isNumericString(a) && isNumericString(b) {
ra, ok1 := new(big.Rat).SetString(phpTrim(a))
rb, ok2 := new(big.Rat).SetString(phpTrim(b))
return ok1 && ok2 && ra.Cmp(rb) == 0
}
return false
}
func inList(s string, list []string) bool {
for _, p := range list {
if phpLooseStringEqual(s, p) {
return true
}
}
return false
}
// validateIn is Laravel's validateIn. An array value needs the array rule
// and no nested element, and then, like count(array_diff($value,
// $parameters)) === 0, every element's string form must equal a parameter
// exactly. A scalar compares like in_array((string) $value, $parameters),
// where two numeric strings compare as numbers.
func (v *requestValidator) validateIn(attr string, value any, params []string) bool {
if _, isArr := arrayLen(value); isArr {
if !v.hasRule(attr, "array") {
return false
}
for _, ch := range children(value) {
if _, nested := arrayLen(ch.value); nested {
return false
}
}
for _, ch := range children(value) {
s, ok := phpScalarString(ch.value)
if !ok || !slices.Contains(params, s) {
return false
}
}
return true
}
s, ok := phpScalarString(value)
return ok && inList(s, params)
}
// validateNotIn is Laravel's validateNotIn: the negation of validateIn, so
// an array passes unless every element is listed, and an array without the
// array rule always passes.
func (v *requestValidator) validateNotIn(attr string, value any, params []string) bool {
return !v.validateIn(attr, value, params)
}
// exists is Laravel's exists:table,column presence check. It counts rows
// whose column, compared as text, equals the value (or, for an array, every
// distinct value); Laravel adds no deleted_at condition and neither does this.
func (v *requestValidator) exists(attr string, rule Rule, value any) (bool, error) {
if v.tx == nil {
return false, fmt.Errorf("lagoon: exists:%s requires a database handle", rule.args[0])
}
table := rule.args[0]
column := attr
if len(rule.args) == 2 && rule.args[1] != "NULL" {
column = rule.args[1]
} else if _, expanded := v.primary[attr]; expanded {
segs := strings.Split(attr, ".")
if last := segs[len(segs)-1]; !isNumericString(last) {
column = last
}
}
if !identName.MatchString(table) || !identName.MatchString(column) {
return false, fmt.Errorf("lagoon: exists identifier %q.%q is not safe", table, column)
}
db := v.tx.WithContext(v.ctx)
if _, isArr := arrayLen(value); isArr {
uniq := map[string]bool{}
var vals []string
for _, ch := range children(value) {
s, ok := phpScalarString(ch.value)
if !ok {
return false, nil
}
if !uniq[s] {
uniq[s] = true
vals = append(vals, s)
}
}
if len(vals) == 0 {
return true, nil
}
var n int64
err := db.Table(table).Where("CAST("+column+" AS TEXT) IN ?", vals).
Distinct("CAST(" + column + " AS TEXT)").Count(&n).Error
if err != nil {
return false, err
}
return n >= int64(len(vals)), nil
}
s, ok := phpScalarString(value)
if !ok {
return false, nil
}
var n int64
if err := db.Table(table).Where("CAST("+column+" AS TEXT) = ?", s).Count(&n).Error; err != nil {
return false, err
}
return n >= 1, nil
}
// validateMimes sniffs the uploaded content (http.DetectContentType, plus
// SVG detection) and maps the type to an extension the way Symfony's
// guessExtension does; jpg and jpeg stand for each other, and a client file
// name ending in a PHP extension is refused unless php is allowed.
func validateMimes(value any, allowed []string) bool {
f, ok := asUploadedFile(value)
if !ok || f.Open == nil {
return false
}
params := make([]string, 0, len(allowed)+2)
hasJPEG, hasPHP := false, false
for _, a := range allowed {
a = strings.ToLower(strings.TrimSpace(a))
params = append(params, a)
hasJPEG = hasJPEG || a == "jpg" || a == "jpeg"
hasPHP = hasPHP || a == "php"
}
if hasJPEG {
params = append(params, "jpg", "jpeg")
}
if !hasPHP {
ext := strings.ToLower(strings.TrimSpace(fileExtension(f.Filename)))
switch ext {
case "php", "php3", "php4", "php5", "php7", "php8", "phtml", "phar":
return false
}
}
guessed := guessExtension(f)
if guessed == "" {
return false
}
for _, p := range params {
if p == guessed {
return true
}
}
return false
}
func fileExtension(name string) string {
if i := strings.LastIndex(name, "."); i >= 0 {
return name[i+1:]
}
return ""
}
var mimeExtensions = map[string]string{
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/bmp": "bmp",
"image/x-ms-bmp": "bmp",
"image/svg+xml": "svg",
"image/x-icon": "ico",
"image/vnd.microsoft.icon": "ico",
"image/avif": "avif",
"application/pdf": "pdf",
"application/zip": "zip",
"application/x-gzip": "gz",
"application/x-rar-compressed": "rar",
"application/ogg": "ogx",
"application/wasm": "wasm",
"application/octet-stream": "bin",
"application/json": "json",
"text/plain": "txt",
"text/html": "html",
"text/xml": "xml",
"text/css": "css",
"audio/mpeg": "mp3",
"audio/wave": "wav",
"audio/aiff": "aif",
"video/mp4": "mp4",
"video/webm": "webm",
"video/avi": "avi",
"font/woff": "woff",
"font/woff2": "woff2",
"font/ttf": "ttf",
"font/otf": "otf",
}
func guessExtension(f UploadedFile) string {
rc, err := f.Open()
if err != nil {
return ""
}
defer rc.Close()
head := make([]byte, 512)
n, err := io.ReadFull(rc, head)
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return ""
}
head = head[:n]
if n == 0 {
return ""
}
mime, _, _ := strings.Cut(http.DetectContentType(head), ";")
mime = strings.TrimSpace(mime)
if strings.HasPrefix(mime, "text/") && looksLikeSVG(head) {
mime = "image/svg+xml"
}
return mimeExtensions[mime]
}
func looksLikeSVG(head []byte) bool {
s := strings.ToLower(string(head))
return strings.Contains(s, "<svg")
}
// validateDate ports Laravel's date rule (strtotime then checkdate) for the
// date shapes listed in parseDateValue.
func validateDate(value any) bool {
s, ok := value.(string)
if !ok {
return false
}
_, ok = parseDateValue(s)
return ok
}
// compareDates ports Laravel's compareDates without date_format: the
// parameter is a date or relative word, else the name of another field
// whose value is the date. An unparsable side compares as PHP compares null
// with an integer (both as booleans).
func (v *requestValidator) compareDates(value any, param, op string) bool {
var vs string
switch t := value.(type) {
case string:
vs = t
default:
if !isNumeric(value) {
return false
}
vs, _ = phpScalarString(value)
}
var second *int64
if t, ok := parseDateArg(param); ok && t.Unix() != 0 {
ts := t.Unix()
second = &ts
} else if other, present := v.lookup(param); present {
if os, isStr := other.(string); isStr {
if t, ok := parseDateArg(os); ok {
ts := t.Unix()
second = &ts
}
}
}
var first *int64
if t, ok := parseDateArg(vs); ok {
ts := t.Unix()
first = &ts
}
return phpCompare(first, second, op)
}
func phpCompare(a, b *int64, op string) bool {
var c int
switch {
case a != nil && b != nil:
switch {
case *a < *b:
c = -1
case *a > *b:
c = 1
}
default:
ab := a != nil && *a != 0
bb := b != nil && *b != 0
switch {
case !ab && bb:
c = -1
case ab && !bb:
c = 1
}
}
switch op {
case ">":
return c > 0
case ">=":
return c >= 0
case "<":
return c < 0
default:
return c <= 0
}
}
// requestNow is the clock for relative date words; tests replace it.
var requestNow = time.Now
// parseDateArg parses a date rule parameter or value the way Carbon::parse
// does for the shapes parseDateValue accepts, plus the relative words today,
// tomorrow, yesterday and now (midnight or the current time, in UTC).
func parseDateArg(s string) (time.Time, bool) {
now := requestNow().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
switch strings.ToLower(strings.TrimSpace(s)) {
case "now":
return now, true
case "today", "midnight":
return midnight, true
case "tomorrow":
return midnight.AddDate(0, 0, 1), true
case "yesterday":
return midnight.AddDate(0, 0, -1), true
}
return parseDateValue(s)
}
var (
dateISO = regexp.MustCompile(`^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?)?\s*(Z|[+-]\d{2}(?::?\d{2})?)?$`)
dateSlash = regexp.MustCompile(`^(\d{4})/(\d{1,2})/(\d{1,2})$`)
dateUS = regexp.MustCompile(`^(\d{1,2})/(\d{1,2})/(\d{4})$`)
dateDot = regexp.MustCompile(`^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$`)
)
// parseDateValue accepts the date shapes the API clients send, all read in
// UTC unless an offset is given: Y-m-d, Y-m-d H:i[:s[.u]] and the ISO 8601
// form with a T, Z or an offset; Y/m/d; m/d/Y (strtotime's American slash
// order); d.m.Y and d-m-Y. The calendar date must exist (checkdate), so
// 2023-02-30 is refused. Other strtotime inputs are refused.
func parseDateValue(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
var y, mo, d, h, mi, sec, nsec int
loc := time.UTC
switch {
case dateISO.MatchString(s):
m := dateISO.FindStringSubmatch(s)
y, mo, d = atoi(m[1]), atoi(m[2]), atoi(m[3])
if m[4] != "" {
h, mi = atoi(m[4]), atoi(m[5])
}
if m[6] != "" {
sec = atoi(m[6])
}
if m[7] != "" {
frac := (m[7] + "000000000")[:9]
nsec = atoi(frac)
}
if z := m[8]; z != "" && z != "Z" {
sign := 1
if z[0] == '-' {
sign = -1
}
digits := strings.ReplaceAll(z[1:], ":", "")
oh := atoi(digits[:2])
om := 0
if len(digits) == 4 {
om = atoi(digits[2:])
}
if oh > 23 || om > 59 {
return time.Time{}, false
}
loc = time.FixedZone("", sign*(oh*3600+om*60))
}
case dateSlash.MatchString(s):
m := dateSlash.FindStringSubmatch(s)
y, mo, d = atoi(m[1]), atoi(m[2]), atoi(m[3])
case dateUS.MatchString(s):
m := dateUS.FindStringSubmatch(s)
mo, d, y = atoi(m[1]), atoi(m[2]), atoi(m[3])
case dateDot.MatchString(s):
m := dateDot.FindStringSubmatch(s)
d, mo, y = atoi(m[1]), atoi(m[2]), atoi(m[3])
default:
return time.Time{}, false
}
if !checkDate(y, mo, d) || h > 23 || mi > 59 || sec > 59 {
return time.Time{}, false
}
return time.Date(y, time.Month(mo), d, h, mi, sec, nsec, loc), true
}
func atoi(s string) int {
n, _ := strconv.Atoi(s)
return n
}
func checkDate(y, m, d int) bool {
if y < 1 || y > 32767 || m < 1 || m > 12 || d < 1 {
return false
}
return d <= time.Date(y, time.Month(m)+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
// filterEmail ports PHP's FILTER_VALIDATE_EMAIL, the check Winter's email
// rule uses by default: ASCII only, under 255 characters, a local part of
// dot-separated atoms or quoted strings up to 64 characters, and a domain of
// at least two dot-separated labels whose last starts with a letter (or is
// an xn-- label), or a bracketed IPv4 or IPv6 literal.
func filterEmail(s string) bool {
if s == "" || len(s) >= 255 {
return false
}
for i := 0; i < len(s); i++ {
if s[i] >= 0x80 {
return false
}
}
at := emailLocalEnd(s)
if at <= 0 || at >= len(s) || s[at] != '@' || at > 64 {
return false
}
return emailDomainOK(s[at+1:])
}
func isAtext(c byte) bool {
switch {
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
return true
}
return strings.IndexByte("!#$%&'*+-/=?^_`{|}~", c) >= 0
}
// emailLocalEnd parses the local part and returns the index of the @ that
// ends it, or -1.
func emailLocalEnd(s string) int {
i := 0
for {
if i >= len(s) {
return -1
}
if s[i] == '"' {
i++
for {
if i >= len(s) {
return -1
}
c := s[i]
if c == '"' {
i++
break
}
if c == '\\' {
if i+1 >= len(s) || s[i+1] > 0x7F {
return -1
}
i += 2
continue
}
if c == 0 || c == '\t' || c == '\n' || c == '\r' || c == ' ' || c > 0x7F {
return -1
}
i++
}
} else {
start := i
for i < len(s) && isAtext(s[i]) {
i++
}
if i == start {
return -1
}
}
if i < len(s) && s[i] == '.' {
i++
continue
}
if i < len(s) && s[i] == '@' {
return i
}
return -1
}
}
var (
emailLabel = regexp.MustCompile(`(?i)^(?:xn--)?[a-z0-9]+(?:-+[a-z0-9]+)*$`)
emailTopLabel = regexp.MustCompile(`(?i)^(?:[a-z][a-z0-9]*|xn--[a-z0-9]+)(?:-+[a-z0-9]+)*$`)
emailIPv4 = regexp.MustCompile(`^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$`)
)
func emailDomainOK(d string) bool {
if strings.HasPrefix(d, "[") && strings.HasSuffix(d, "]") {
lit := d[1 : len(d)-1]
if len(lit) > 5 && strings.EqualFold(lit[:5], "IPv6:") {
ip := net.ParseIP(lit[5:])
return ip != nil && strings.Contains(lit[5:], ":")
}
return emailIPv4.MatchString(lit)
}
labels := strings.Split(d, ".")
if len(labels) < 2 || len(labels) > 127 {
return false
}
for i, l := range labels {
if len(l) >= 64 {
return false
}
if i == len(labels)-1 {
if !emailTopLabel.MatchString(l) {
return false
}
continue
}
if !emailLabel.MatchString(l) {
return false
}
}
return true
}
// urlPattern is Laravel 9's validateUrl pattern (derived from Symfony's
// UrlValidator) rewritten for RE2: the same protocols, optional basic auth,
// a domain name, IPv4 or bracketed IPv6 host, optional port, path, query
// and fragment, matched case-insensitively.
var urlPattern = regexp.MustCompile(`(?i)^` +
`(aaa|aaas|about|acap|acct|acd|acr|adiumxtra|adt|afp|afs|aim|amss|android|appdata|apt|ark|attachment|aw|barion|beshare|bitcoin|bitcoincash|blob|bolo|browserext|calculator|callto|cap|cast|casts|chrome|chrome-extension|cid|coap|coap\+tcp|coap\+ws|coaps|coaps\+tcp|coaps\+ws|com-eventbrite-attendee|content|conti|crid|cvs|dab|data|dav|diaspora|dict|did|dis|dlna-playcontainer|dlna-playsingle|dns|dntp|dpp|drm|drop|dtn|dvb|ed2k|elsi|example|facetime|fax|feed|feedready|file|filesystem|finger|first-run-pen-experience|fish|fm|ftp|fuchsia-pkg|geo|gg|git|gizmoproject|go|gopher|graph|gtalk|h323|ham|hcap|hcp|http|https|hxxp|hxxps|hydrazone|iax|icap|icon|im|imap|info|iotdisco|ipn|ipp|ipps|irc|irc6|ircs|iris|iris\.beep|iris\.lwz|iris\.xpc|iris\.xpcs|isostore|itms|jabber|jar|jms|keyparc|lastfm|ldap|ldaps|leaptofrogans|lorawan|lvlt|magnet|mailserver|mailto|maps|market|message|mid|mms|modem|mongodb|moz|ms-access|ms-browser-extension|ms-calculator|ms-drive-to|ms-enrollment|ms-excel|ms-eyecontrolspeech|ms-gamebarservices|ms-gamingoverlay|ms-getoffice|ms-help|ms-infopath|ms-inputapp|ms-lockscreencomponent-config|ms-media-stream-id|ms-mixedrealitycapture|ms-mobileplans|ms-officeapp|ms-people|ms-project|ms-powerpoint|ms-publisher|ms-restoretabcompanion|ms-screenclip|ms-screensketch|ms-search|ms-search-repair|ms-secondary-screen-controller|ms-secondary-screen-setup|ms-settings|ms-settings-airplanemode|ms-settings-bluetooth|ms-settings-camera|ms-settings-cellular|ms-settings-cloudstorage|ms-settings-connectabledevices|ms-settings-displays-topology|ms-settings-emailandaccounts|ms-settings-language|ms-settings-location|ms-settings-lock|ms-settings-nfctransactions|ms-settings-notifications|ms-settings-power|ms-settings-privacy|ms-settings-proximity|ms-settings-screenrotation|ms-settings-wifi|ms-settings-workplace|ms-spd|ms-sttoverlay|ms-transit-to|ms-useractivityset|ms-virtualtouchpad|ms-visio|ms-walk-to|ms-whiteboard|ms-whiteboard-cmd|ms-word|msnim|msrp|msrps|mss|mtqp|mumble|mupdate|mvn|news|nfs|ni|nih|nntp|notes|ocf|oid|onenote|onenote-cmd|opaquelocktoken|openpgp4fpr|pack|palm|paparazzi|payto|pkcs11|platform|pop|pres|prospero|proxy|pwid|psyc|pttp|qb|query|redis|rediss|reload|res|resource|rmi|rsync|rtmfp|rtmp|rtsp|rtsps|rtspu|s3|secondlife|service|session|sftp|sgn|shttp|sieve|simpleledger|sip|sips|skype|smb|sms|smtp|snews|snmp|soap\.beep|soap\.beeps|soldat|spiffe|spotify|ssh|steam|stun|stuns|submit|svn|tag|teamspeak|tel|teliaeid|telnet|tftp|tg|things|thismessage|tip|tn3270|tool|ts3server|turn|turns|tv|udp|unreal|urn|ut2004|v-event|vemmi|ventrilo|videotex|vnc|view-source|wais|webcal|wpid|ws|wss|wtai|wyciwyg|xcon|xcon-userid|xfire|xmlrpc\.beep|xmlrpc\.beeps|xmpp|xri|ymsgr|z39\.50|z39\.50r|z39\.50s)://` +
`(((?:[_.\pL\pN-]|%[0-9A-Fa-f]{2})+:)?((?:[_.\pL\pN-]|%[0-9A-Fa-f]{2})+)@)?` +
`(` +
`([\pL\pN\pS\-_.])+(\.?([\pL\pN]|xn--[\pL\pN-]+)+\.?)` +
`|` +
`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}` +
`|` +
`\[` + urlIPv6 + `\]` +
`)` +
`(:[0-9]+)?` +
`(?:/(?:[\pL\pN\-._~!$&'()*+,;=:@]|%[0-9A-Fa-f]{2})*)*` +
`(?:\?(?:[\pL\pN\-._~!$&'\[\]()*+,;=:@/?]|%[0-9A-Fa-f]{2})*)?` +
`(?:#(?:[\pL\pN\-._~!$&'()*+,;=:@/?]|%[0-9A-Fa-f]{2})*)?` +
`$`)
const urlIPv6 = `(?:(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){6})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:::(?:(?:(?:[0-9a-f]{1,4})):){5})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){4})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,1}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){3})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,2}(?:(?:[0-9a-f]{1,4})))?::(?:(?:(?:[0-9a-f]{1,4})):){2})(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,3}(?:(?:[0-9a-f]{1,4})))?::(?:(?:[0-9a-f]{1,4})):)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,4}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:(?:(?:(?:[0-9a-f]{1,4})):(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9]))\.){3}(?:(?:25[0-5]|(?:[1-9]|1[0-9]|2[0-4])?[0-9])))))))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,5}(?:(?:[0-9a-f]{1,4})))?::)(?:(?:[0-9a-f]{1,4})))|(?:(?:(?:(?:(?:(?:[0-9a-f]{1,4})):){0,6}(?:(?:[0-9a-f]{1,4})))?::))))`