1<?php 2 3/** 4 * This file is part of the Nette Framework (https://nette.org) 5 * Copyright (c) 2004 David Grudl (https://davidgrudl.com) 6 */ 7 8declare(strict_types=1); 9 10namespace Nette\Utils; 11 12use Nette; 13 14 15/** 16 * Validation utilities. 17 */ 18class Validators 19{ 20 use Nette\StaticClass; 21 22 private const BuiltinTypes = [ 23 'string' => 1, 'int' => 1, 'float' => 1, 'bool' => 1, 'array' => 1, 'object' => 1, 24 'callable' => 1, 'iterable' => 1, 'void' => 1, 'null' => 1, 'mixed' => 1, 'false' => 1, 25 'never' => 1, 'true' => 1, 26 ]; 27 28 /** @var array<string,?callable> */ 29 protected static $validators = [ 30 // PHP types 31 'array' => 'is_array', 32 'bool' => 'is_bool', 33 'boolean' => 'is_bool', 34 'float' => 'is_float', 35 'int' => 'is_int', 36 'integer' => 'is_int', 37 'null' => 'is_null', 38 'object' => 'is_object', 39 'resource' => 'is_resource', 40 'scalar' => 'is_scalar', 41 'string' => 'is_string', 42 43 // pseudo-types 44 'callable' => [self::class, 'isCallable'], 45 'iterable' => 'is_iterable', 46 'list' => [Arrays::class, 'isList'], 47 'mixed' => [self::class, 'isMixed'], 48 'none' => [self::class, 'isNone'], 49 'number' => [self::class, 'isNumber'], 50 'numeric' => [self::class, 'isNumeric'], 51 'numericint' => [self::class, 'isNumericInt'], 52 53 // string patterns 54 'alnum' => 'ctype_alnum', 55 'alpha' => 'ctype_alpha', 56 'digit' => 'ctype_digit', 57 'lower' => 'ctype_lower', 58 'pattern' => null, 59 'space' => 'ctype_space', 60 'unicode' => [self::class, 'isUnicode'], 61 'upper' => 'ctype_upper', 62 'xdigit' => 'ctype_xdigit', 63 64 // syntax validation 65 'email' => [self::class, 'isEmail'], 66 'identifier' => [self::class, 'isPhpIdentifier'], 67 'uri' => [self::class, 'isUri'], 68 'url' => [self::class, 'isUrl'], 69 70 // environment validation 71 'class' => 'class_exists', 72 'interface' => 'interface_exists', 73 'directory' => 'is_dir', 74 'file' => 'is_file', 75 'type' => [self::class, 'isType'], 76 ]; 77 78 /** @var array<string,callable> */ 79 protected static $counters = [ 80 'string' => 'strlen', 81 'unicode' => [Strings::class, 'length'], 82 'array' => 'count', 83 'list' => 'count', 84 'alnum' => 'strlen', 85 'alpha' => 'strlen', 86 'digit' => 'strlen', 87 'lower' => 'strlen', 88 'space' => 'strlen', 89 'upper' => 'strlen', 90 'xdigit' => 'strlen', 91 ]; 92 93 94 /** 95 * Verifies that the value is of expected types separated by pipe. 96 * @throws AssertionException 97 */ 98 public static function assert(mixed $value, string $expected, string $label = 'variable'): void 99 { 100 if (!static::is($value, $expected)) { 101 $expected = str_replace(['|', ':'], [' or ', ' in range '], $expected); 102 $translate = ['boolean' => 'bool', 'integer' => 'int', 'double' => 'float', 'NULL' => 'null']; 103 $type = $translate[gettype($value)] ?? gettype($value); 104 if (is_int($value) || is_float($value) || (is_string($value) && strlen($value) < 40)) { 105 $type .= ' ' . var_export($value, true); 106 } elseif (is_object($value)) { 107 $type .= ' ' . $value::class; 108 } 109 110 throw new AssertionException("The $label expects to be $expected, $type given."); 111 } 112 } 113 114 115 /** 116 * Verifies that element $key in array is of expected types separated by pipe. 117 * @param mixed[] $array 118 * @throws AssertionException 119 */ 120 public static function assertField( 121 array $array, 122 $key, 123 ?string $expected = null, 124 string $label = "item '%' in array", 125 ): void 126 { 127 if (!array_key_exists($key, $array)) { 128 throw new AssertionException('Missing ' . str_replace('%', $key, $label) . '.'); 129 130 } elseif ($expected) { 131 static::assert($array[$key], $expected, str_replace('%', $key, $label)); 132 } 133 } 134 135 136 /** 137 * Verifies that the value is of expected types separated by pipe. 138 */ 139 public static function is(mixed $value, string $expected): bool 140 { 141 foreach (explode('|', $expected) as $item) { 142 if (str_ends_with($item, '[]')) { 143 if (is_iterable($value) && self::everyIs($value, substr($item, 0, -2))) { 144 return true; 145 } 146 147 continue; 148 } elseif (str_starts_with($item, '?')) { 149 $item = substr($item, 1); 150 if ($value === null) { 151 return true; 152 } 153 } 154 155 [$type] = $item = explode(':', $item, 2); 156 if (isset(static::$validators[$type])) { 157 try { 158 if (!static::$validators[$type]($value)) { 159 continue; 160 } 161 } catch (\TypeError $e) { 162 continue; 163 } 164 } elseif ($type === 'pattern') { 165 if (Strings::match($value, '|^' . ($item[1] ?? '') . '$|D')) { 166 return true; 167 } 168 169 continue; 170 } elseif (!$value instanceof $type) { 171 continue; 172 } 173 174 if (isset($item[1])) { 175 $length = $value; 176 if (isset(static::$counters[$type])) { 177 $length = static::$counters[$type]($value); 178 } 179 180 $range = explode('..', $item[1]); 181 if (!isset($range[1])) { 182 $range[1] = $range[0]; 183 } 184 185 if (($range[0] !== '' && $length < $range[0]) || ($range[1] !== '' && $length > $range[1])) { 186 continue; 187 } 188 } 189 190 return true; 191 } 192 193 return false; 194 } 195 196 197 /** 198 * Finds whether all values are of expected types separated by pipe. 199 * @param mixed[] $values 200 */ 201 public static function everyIs(iterable $values, string $expected): bool 202 { 203 foreach ($values as $value) { 204 if (!static::is($value, $expected)) { 205 return false; 206 } 207 } 208 209 return true; 210 } 211 212 213 /** 214 * Checks if the value is an integer or a float. 215 * @return ($value is int|float ? true : false) 216 */ 217 public static function isNumber(mixed $value): bool 218 { 219 return is_int($value) || is_float($value); 220 } 221 222 223 /** 224 * Checks if the value is an integer or a integer written in a string. 225 * @return ($value is non-empty-string ? bool : ($value is int ? true : false)) 226 */ 227 public static function isNumericInt(mixed $value): bool 228 { 229 return is_int($value) || (is_string($value) && preg_match('#^[+-]?[0-9]+$#D', $value)); 230 } 231 232 233 /** 234 * Checks if the value is a number or a number written in a string. 235 * @return ($value is non-empty-string ? bool : ($value is int|float ? true : false)) 236 */ 237 public static function isNumeric(mixed $value): bool 238 { 239 return is_float($value) || is_int($value) || (is_string($value) && preg_match('#^[+-]?([0-9]++\.?[0-9]*|\.[0-9]+)$#D', $value)); 240 } 241 242 243 /** 244 * Checks if the value is a syntactically correct callback. 245 */ 246 public static function isCallable(mixed $value): bool 247 { 248 return $value && is_callable($value, true); 249 } 250 251 252 /** 253 * Checks if the value is a valid UTF-8 string. 254 */ 255 public static function isUnicode(mixed $value): bool 256 { 257 return is_string($value) && preg_match('##u', $value); 258 } 259 260 261 /** 262 * Checks if the value is 0, '', false or null. 263 * @return ($value is 0|''|false|null ? true : false) 264 */ 265 public static function isNone(mixed $value): bool 266 { 267 return $value == null; // intentionally == 268 } 269 270 271 /** @internal */ 272 public static function isMixed(): bool 273 { 274 return true; 275 } 276 277 278 /** 279 * Checks if a variable is a zero-based integer indexed array. 280 * @deprecated use Nette\Utils\Arrays::isList 281 * @return ($value is list ? true : false) 282 */ 283 public static function isList(mixed $value): bool 284 { 285 return Arrays::isList($value); 286 } 287 288 289 /** 290 * Checks if the value is in the given range [min, max], where the upper or lower limit can be omitted (null). 291 * Numbers, strings and DateTime objects can be compared. 292 */ 293 public static function isInRange(mixed $value, array $range): bool 294 { 295 if ($value === null || !(isset($range[0]) || isset($range[1]))) { 296 return false; 297 } 298 299 $limit = $range[0] ?? $range[1]; 300 if (is_string($limit)) { 301 $value = (string) $value; 302 } elseif ($limit instanceof \DateTimeInterface) { 303 if (!$value instanceof \DateTimeInterface) { 304 return false; 305 } 306 } elseif (is_numeric($value)) { 307 $value *= 1; 308 } else { 309 return false; 310 } 311 312 return (!isset($range[0]) || ($value >= $range[0])) && (!isset($range[1]) || ($value <= $range[1])); 313 } 314 315 316 /** 317 * Checks if the value is a valid email address. It does not verify that the domain actually exists, only the syntax is verified. 318 */ 319 public static function isEmail(string $value): bool 320 { 321 $atom = "[-a-z0-9!#$%&'*+/=?^_`{|}~]"; // RFC 5322 unquoted characters in local-part 322 $alpha = "a-z\x80-\xFF"; // superset of IDN 323 return (bool) preg_match(<<<XX 324 (^(?n) 325 ("([ !#-[\\]-~]*|\\\\[ -~])+"|$atom+(\\.$atom+)*) # quoted or unquoted 326 @ 327 ([0-9$alpha]([-0-9$alpha]{0,61}[0-9$alpha])?\\.)+ # domain - RFC 1034 328 [$alpha]([-0-9$alpha]{0,17}[$alpha])? # top domain 329 $)Dix 330 XX, $value); 331 } 332 333 334 /** 335 * Checks if the value is a valid URL address. 336 */ 337 public static function isUrl(string $value): bool 338 { 339 $alpha = "a-z\x80-\xFF"; 340 return (bool) preg_match(<<<XX 341 (^(?n) 342 https?://( 343 (([-_0-9$alpha]+\\.)* # subdomain 344 [0-9$alpha]([-0-9$alpha]{0,61}[0-9$alpha])?\\.)? # domain 345 [$alpha]([-0-9$alpha]{0,17}[$alpha])? # top domain 346 |\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3} # IPv4 347 |\\[[0-9a-f:]{3,39}\\] # IPv6 348 )(:\\d{1,5})? # port 349 (/\\S*)? # path 350 (\\?\\S*)? # query 351 (\\#\\S*)? # fragment 352 $)Dix 353 XX, $value); 354 } 355 356 357 /** 358 * Checks if the value is a valid URI address, that is, actually a string beginning with a syntactically valid schema. 359 */ 360 public static function isUri(string $value): bool 361 { 362 return (bool) preg_match('#^[a-z\d+\.-]+:\S+$#Di', $value); 363 } 364 365 366 /** 367 * Checks whether the input is a class, interface or trait. 368 * @deprecated 369 */ 370 public static function isType(string $type): bool 371 { 372 return class_exists($type) || interface_exists($type) || trait_exists($type); 373 } 374 375 376 /** 377 * Checks whether the input is a valid PHP identifier. 378 */ 379 public static function isPhpIdentifier(string $value): bool 380 { 381 return preg_match('#^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$#D', $value) === 1; 382 } 383 384 385 /** 386 * Determines if type is PHP built-in type. Otherwise, it is the class name. 387 */ 388 public static function isBuiltinType(string $type): bool 389 { 390 return isset(self::BuiltinTypes[strtolower($type)]); 391 } 392 393 394 /** 395 * Determines if type is special class name self/parent/static. 396 */ 397 public static function isClassKeyword(string $name): bool 398 { 399 return (bool) preg_match('#^(self|parent|static)$#Di', $name); 400 } 401 402 403 /** 404 * Checks whether the given type declaration is syntactically valid. 405 */ 406 public static function isTypeDeclaration(string $type): bool 407 { 408 return (bool) preg_match(<<<'XX' 409 ~((?n) 410 \?? (?<type> \\? (?<name> [a-zA-Z_\x7f-\xff][\w\x7f-\xff]*) (\\ (?&name))* ) | 411 (?<intersection> (?&type) (& (?&type))+ ) | 412 (?<upart> (?&type) | \( (?&intersection) \) ) (\| (?&upart))+ 413 )$~xAD 414 XX, $type); 415 } 416} 417