_confirmation' field | * | required_with | :fields | Required when a listed field is present | * | required_if | :field,:value | Required when a field equals a value | * | file | | Value must be an uploaded file ($_FILES) | * | image | | Uploaded file must be an image (real MIME) | * | mimes | :ext,ext | Uploaded file's real MIME must match | * |---------------+---------------+---------------------------------------------| * * @author KJ * @website https://kj2.me * @license MIT */ class Validator { /** * Stores the last failed rule. * * @var string */ public static string $lastFailed = ''; /** * Rules that need to know the current field name and the whole data set * (e.g. to look up sibling fields such as the `confirmed` companion). * For these rules, checkRule() injects $field and $haystack as extra * arguments before the user-provided ones. * * @var string[] */ private const DATA_AWARE_RULES = ['confirmed', 'required_with', 'required_if']; /** * Rules whose mere presence marks a field as a "file field". When a field's * rule list contains any of these, the shared rules below are routed to * their file-specific variants (see checkRule / validateList). * * @var string[] */ private const FILE_MARKER_RULES = ['file', 'image', 'mimes']; /** * Shared rules that gain file semantics (kilobytes / upload presence) and * are therefore dispatched to `file_()` for a file field. * * @var string[] */ private const FILE_AWARE_RULES = ['min', 'max', 'between', 'size', 'required', 'nullable']; /** * Default error message template for each rule, used by message() to build * the text for the first failing rule. * * Supported placeholders: * |------------+--------------------------------------------------| * | Marker | Replaced with | * |------------+--------------------------------------------------| * | :attribute | attributes[campo] if given, else the field name | * | :min | the min/between lower bound | * | :max | the max/between upper bound | * | :size | the size rule value | * | :value | the required_if expected value | * | :other | the required_if companion field / confirmation | * | :values | a comma list (enum, mimes, required_with) | * |----------+----------------------------------------------------| * * @var array */ public static array $messageTemplates = [ 'not' => 'The :attribute is not valid.', 'exists' => 'The :attribute field is missing.', 'required' => 'The :attribute field is required.', 'number' => 'The :attribute must be a number.', 'int' => 'The :attribute must be an integer.', 'float' => 'The :attribute must be a float.', 'bool' => 'The :attribute must be a boolean.', 'string' => 'The :attribute must be a string.', 'array' => 'The :attribute must be an array.', 'email' => 'The :attribute must be a valid email address.', 'url' => 'The :attribute must be a valid URL.', 'enum' => 'The selected :attribute is invalid. Allowed: :values.', 'min' => 'The :attribute must be at least :min.', 'max' => 'The :attribute must not be greater than :max.', 'between' => 'The :attribute must be between :min and :max.', 'size' => 'The :attribute must be exactly :size.', 'regex' => 'The :attribute format is invalid.', 'date' => 'The :attribute is not a valid date.', 'confirmed' => 'The :attribute confirmation does not match.', 'required_with' => 'The :attribute field is required when :values is present.', 'required_if' => 'The :attribute field is required when :other is :value.', 'nullable' => 'The :attribute field may be null.', 'file' => 'The :attribute must be a file.', 'image' => 'The :attribute must be an image.', 'mimes' => 'The :attribute must be a file of type: :values.', ]; /** * Known MIME types per file extension, used by the mimes rule to compare * the detected (real) MIME type of an upload against the allowed list. * * An extension maps to a set because different systems report slightly * different types for the same format (e.g. "jpg" => image/jpeg/pjpeg). * Public so applications can add formats without touching the library. * * @var array */ public static array $mimeTypes = [ 'jpg' => ['image/jpeg', 'image/pjpeg'], 'jpeg' => ['image/jpeg', 'image/pjpeg'], 'png' => ['image/png'], 'gif' => ['image/gif'], 'bmp' => ['image/bmp', 'image/x-ms-bmp'], 'webp' => ['image/webp'], 'svg' => ['image/svg+xml'], 'ico' => ['image/vnd.microsoft.icon', 'image/x-icon'], 'tif' => ['image/tiff'], 'tiff' => ['image/tiff'], 'pdf' => ['application/pdf'], 'txt' => ['text/plain'], 'csv' => ['text/plain', 'text/csv', 'application/csv'], 'json' => ['application/json'], 'html' => ['text/html'], 'xml' => ['text/xml', 'application/xml', 'text/plain'], 'mp3' => ['audio/mpeg', 'audio/mp3'], 'wav' => ['audio/wav', 'audio/x-wav'], 'ogg' => ['audio/ogg', 'application/ogg'], 'flac' => ['audio/flac', 'audio/x-flac'], 'mp4' => ['video/mp4'], 'm4a' => ['audio/mp4', 'video/mp4'], 'webm' => ['video/webm'], 'mov' => ['video/quicktime'], 'avi' => ['video/x-msvideo', 'video/avi'], 'zip' => ['application/zip', 'application/x-zip', 'application/x-zip-compressed'], 'gz' => ['application/gzip', 'application/x-gzip'], 'tar' => ['application/x-tar'], 'rar' => ['application/vnd.rar', 'application/x-rar-compressed', 'application/rar'], '7z' => ['application/x-7z-compressed'], 'doc' => ['application/msword'], 'docx' => ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], 'xls' => ['application/vnd.ms-excel'], 'xlsx' => ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], 'ppt' => ['application/vnd.ms-powerpoint'], 'pptx' => ['application/vnd.openxmlformats-officedocument.presentationml.presentation'], ]; /** * Builds the human-readable message for a single failed validation. * * Pure helper: it only reads its arguments and never reads nor writes * $lastFailed or any other state. Request::validate() uses it to produce * the single error message it sends, forwarding its messages() and * attributes() maps; it can also be called directly by controllers. * * Resolution order for the message text: * 1. $messages[$lastFailed] override by full "field.rule:args" * 2. $messages["field.rule"] override by "field.rule" (no args) * 3. $messageTemplates[rule] default for the rule * Placeholders are then substituted in whichever text was chosen. * * @param string $lastFailed The "field.rule[:args]" produced by validateList(). * @param array $messages Optional per-field overrides. * @param array $attributes Optional human field names ("field" => "Field"). * * @return string */ public static function message(string $lastFailed, array $messages = [], array $attributes = []): string { $separator = strpos($lastFailed, '.'); if ($separator === false) { return 'The :attribute is invalid.'; } $target = substr($lastFailed, 0, $separator); $ruleStr = substr($lastFailed, $separator + 1); [$rule, $rawArguments] = static::parseRule($ruleStr); $text = $messages[$lastFailed] ?? $messages[$target . '.' . $rule] ?? (static::$messageTemplates[$rule] ?? 'The :attribute is invalid.'); $replace = [ ':attribute' => $attributes[$target] ?? $target, ]; $args = static::splitArguments($rule, $rawArguments); switch ($rule) { case 'min': $replace[':min'] = $args[0] ?? ''; break; case 'max': $replace[':max'] = $args[0] ?? ''; break; case 'between': $replace[':min'] = $args[0] ?? ''; $replace[':max'] = $args[1] ?? ''; break; case 'size': $replace[':size'] = $args[0] ?? ''; break; case 'enum': case 'mimes': case 'required_with': $replace[':values'] = implode(', ', $args); break; case 'required_if': $replace[':other'] = $args[0] ?? ''; $replace[':value'] = $args[1] ?? ''; break; } return strtr($text, $replace); } /** * Validates a list of rules against the properties of an object. * * @param array $rulesList The list of rules. * @param Neuron $haystack The object whose properties will be validated. * * @return bool Returns true only if all rules are met, and false as soon as one fails. */ public static function validateList(array $rulesList, Neuron $haystack): bool { foreach ($rulesList as $target => $rules) { $rules = preg_split('/\|/', $rules); $value = $haystack->{$target}; $ruleNames = array_map(static fn ($r) => static::parseRule($r)[0], $rules); $isFileField = array_intersect($ruleNames, self::FILE_MARKER_RULES) !== []; if (in_array('nullable', $ruleNames, true)) { $empty = $isFileField ? !static::hasUploadedFile($value) : static::isEmpty($value); if ($empty) { continue; } } $ruleValidator = $isFileField ? 'checkFileRule' : 'checkRule'; foreach ($rules as $rule) { if (static::$ruleValidator($value, $rule, $target, $haystack)) { continue; } static::$lastFailed = $target . '.' . $rule; return false; } } return true; } /** * Splits a single rule string into its name and its raw argument string. * * The rule name is everything before the first ':'. The remainder is * returned verbatim (it is NOT split further here) so that rules whose * arguments legitimately contain ':' or ',' - such as regex or mimes - * are preserved intact. The caller decides how to split the arguments. * * @param string $rule The rule to parse. Ex: "regex:/^a,b$/" or "enum:a,b". * * @return array A two element array: [name, rawArguments]. When the rule * has no parameters, rawArguments is an empty string. */ public static function parseRule(string $rule): array { $separator = strpos($rule, ':'); if ($separator === false) { return [$rule, '']; } return [substr($rule, 0, $separator), substr($rule, $separator + 1)]; } /** * Checks if a scalar rule is met. * * @param mixed $subject The value to verify. * @param string $rule The rule to test. * @param string|null $field Optional name of the field being validated * (used by "data-aware" rules to locate siblings). * @param mixed $haystack Optional full data set (Neuron or array). * * @return bool * @throws Exception If the rule is not callable. */ public static function checkRule( mixed $subject, string $rule, ?string $field = null, mixed $haystack = null ): bool { [$name, $rawArguments] = static::parseRule($rule); return static::callRuleMethod($name, $rawArguments, $subject, $field, $haystack); } /** * Variant of checkRule() for file fields. * * Shares the same dispatch but reroutes the size-based and presence rules * (see FILE_AWARE_RULES) to their `file_*` counterparts, so min/max/between/ * size work on kilobytes and required works on uploads. Marker rules * (file/image/mimes) and everything else run unchanged. * * @param mixed $subject The value to verify (a $_FILES entry). * @param string $rule The rule to test. * @param string|null $field Name of the field being validated. * @param mixed $haystack The full data set (Neuron or array). * * @return bool * @throws Exception If the rule is not callable. */ public static function checkFileRule( mixed $subject, string $rule, ?string $field = null, mixed $haystack = null ): bool { [$name, $rawArguments] = static::parseRule($rule); if (in_array($name, self::FILE_AWARE_RULES, true)) { $name = 'file_' . $name; } return static::callRuleMethod($name, $rawArguments, $subject, $field, $haystack); } /** * Resolves a rule name to its method and invokes it with the right arguments. * * Data-aware rules receive the field name and haystack; every other rule gets * just the subject plus its comma-split arguments. * * @param string $name The resolved rule/method name. * @param string $rawArguments Raw argument string from parseRule(). * @param mixed $subject The value being validated. * @param string|null $field Field name (for data-aware rules). * @param mixed $haystack Full data set (for data-aware rules). * * @return bool * @throws Exception If the rule is not callable. */ private static function callRuleMethod( string $name, string $rawArguments, mixed $subject, ?string $field, mixed $haystack ): bool { $method = [static::class, $name]; if (in_array($name, self::DATA_AWARE_RULES, true)) { $arguments = array_merge( [$subject, $field, $haystack], static::splitArguments($name, $rawArguments) ); } else { $arguments = array_merge([$subject], static::splitArguments($name, $rawArguments)); } if (is_callable($method)) { return call_user_func_array($method, $arguments); } throw new Exception('Bad rule: "' . $name . '"'); } /** * Splits a rule's raw argument string into the arguments to pass to the * rule's method (the subject is not included). * * Most rules take a comma-separated list of values (e.g. "enum:a,b"). * A few rules receive an argument that must be kept intact because it can * legitimately contain commas or colons: "regex" (a pattern) and "not" * (a sub-rule that is itself parsed recursively). An empty argument string * yields no extra arguments. * * @param string $name The rule name. * @param string $raw The raw argument string as returned by parseRule(). * * @return array The list of arguments to pass after the subject. */ private static function splitArguments(string $name, string $raw): array { if ($raw === '') { return []; } $intactRules = ['regex', 'not']; if (in_array($name, $intactRules, true)) { return [$raw]; } return explode(',', $raw); } /** * Verifies the rule in a negative way. * * @param mixed $subject The value to verify. * @param mixed $rule The rule to test. * * @return bool */ public static function not(mixed $subject, ...$rule): bool { return !static::checkRule($subject, join(':', $rule)); } /** * Checks if the value is defined/exists. * * @param mixed $subject The value to check. * * @return bool */ public static function exists(mixed $subject): bool { return isset($subject); } /** * Checks if the value is defined and not empty. * * @param mixed $subject The value to check. * * @return bool */ public static function required(mixed $subject): bool { return isset($subject) && !empty($subject); } /** * Checks if the value is numeric. * * @param mixed $subject The value to check. * * @return bool */ public static function number(mixed $subject): bool { return is_numeric($subject); } /** * Checks if the value is an integer. * * @param mixed $subject The value to check. * * @return bool */ public static function int(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_INT) !== false; } /** * Checks if the value is a float. * * @param mixed $subject The value to check. * * @return bool */ public static function float(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_FLOAT) !== false; } /** * Checks if the value is a boolean. * * @param mixed $subject The value to check. * * @return bool */ public static function bool(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) !== null; } /** * Checks if the value is a string. * * Note this is the strict type check: a PHP integer (e.g. 42) or an array * fails, while any string - including a numeric string like "42" - passes. * * @param mixed $subject The value to check. * * @return bool */ public static function string(mixed $subject): bool { return is_string($subject); } /** * Checks if the value is an array. * * @param mixed $subject The value to check. * * @return bool */ public static function array(mixed $subject): bool { return is_array($subject); } /** * Measures a value for the size-based rules (min/max/between/size). * * A numeric value is measured by its number, a plain string by its * character length, and an array by its element count. This single helper * keeps the size semantics consistent and is where the file case (kilobytes) * is added later. * * @param mixed $subject The value to measure. * * @return float */ private static function measure(mixed $subject): float { if (is_numeric($subject)) { return (float) $subject; } if (is_string($subject)) { return (float) (function_exists('mb_strlen') ? mb_strlen($subject) : strlen($subject)); } if (is_array($subject)) { return (float) count($subject); } return 0.0; } /** * Checks if the measured value is greater than or equal to a minimum. * * @param mixed $subject The value to check. * @param mixed $min The minimum length, number or count. * * @return bool */ public static function min(mixed $subject, mixed $min): bool { return static::measure($subject) >= (float) $min; } /** * Checks if the measured value is less than or equal to a maximum. * * @param mixed $subject The value to check. * @param mixed $max The maximum length, number or count. * * @return bool */ public static function max(mixed $subject, mixed $max): bool { return static::measure($subject) <= (float) $max; } /** * Checks if the measured value is within an inclusive range. * * @param mixed $subject The value to check. * @param mixed $min The lower bound. * @param mixed $max The upper bound. * * @return bool */ public static function between(mixed $subject, mixed $min, mixed $max): bool { $size = static::measure($subject); return $size >= (float) $min && $size <= (float) $max; } /** * Checks if the measured value is exactly a given size. * * @param mixed $subject The value to check. * @param mixed $size The expected length, number or count. * * @return bool */ public static function size(mixed $subject, mixed $size): bool { return static::measure($subject) === (float) $size; } /** * Checks if the value matches a regular expression. * * The pattern is received verbatim (delimiters included), thanks to * splitArguments() treating "regex" as an intact argument, so patterns * that contain ':' or ',' - such as "/^a,b$/" - work as expected. * * @param mixed $subject The value to check. * @param string $pattern A PCRE pattern including its delimiters. * * @return bool */ public static function regex(mixed $subject, mixed $pattern): bool { return preg_match((string) $pattern, (string) $subject) === 1; } /** * Checks if the value is a parseable date. * * Any format accepted by DateTime is valid (e.g. "2026-09-03", an ISO * datetime, or a timestamp string). Empty and non-scalar values fail. * * @param mixed $subject The value to check. * * @return bool */ public static function date(mixed $subject): bool { if (!is_scalar($subject) || trim((string) $subject) === '') { return false; } try { new \DateTime((string) $subject); return true; } catch (Exception $e) { return false; } } /** * Marks a field as allowed to be empty. * * As a rule it always passes; the real effect happens in validateList(), * which skips all the other rules for a field when "nullable" is present * and the value is empty (see isEmpty()). * * @param mixed $subject The value to check. * * @return bool Always true. */ public static function nullable(mixed $subject): bool { return true; } /** * Checks that the field has a matching companion "_confirmation". * * @param mixed $subject The value to check. * @param string|null $field Name of the field being validated. * @param mixed $haystack The full data set (Neuron or array). * * @return bool */ public static function confirmed(mixed $subject, ?string $field = null, mixed $haystack = null): bool { if ($field === null || $haystack === null) { return false; } return static::readField($haystack, $field . '_confirmation') === $subject; } // phpcs:disable PSR1.Methods.CamelCapsMethodName.NotCamelCaps /** * Requires the field only when at least one of the listed fields is present. * * @param mixed $subject The value to check. * @param string $field Name of the field being validated (unused here). * @param mixed $haystack The full data set (Neuron or array). * @param string ...$others Names of the fields whose presence triggers the requirement. * * @return bool */ public static function required_with( mixed $subject, ?string $field = null, mixed $haystack = null, string ...$others ): bool { if ($haystack === null || empty($others)) { return true; } foreach ($others as $other) { if (!static::isEmpty(static::readField($haystack, $other))) { return isset($subject) && !empty($subject); } } return true; } /** * Requires the field only when another field equals a given value. * * @param mixed $subject The value to check. * @param string $field Name of the field being validated (unused here). * @param mixed $haystack The full data set (Neuron or array). * @param string|null $other Name of the field to inspect. * @param mixed $value Expected value that triggers the requirement. * * @return bool */ public static function required_if( mixed $subject, ?string $field = null, mixed $haystack = null, ?string $other = null, mixed $value = null ): bool { if ($haystack === null || $other === null) { return true; } $otherValue = static::readField($haystack, $other); if (is_scalar($otherValue) && (string) $otherValue === (string) $value) { return isset($subject) && !empty($subject); } return true; } /** * Checks if the value is a valid email address. * * @param mixed $subject The value to check. * * @return bool */ public static function email(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_EMAIL) !== false; } /** * Checks if the value is a valid URL. * * @param mixed $subject The value to check. * * @return bool */ public static function url(mixed $subject): bool { return filter_var($subject, FILTER_VALIDATE_URL) !== false; } /** * Checks if the value is present in a list of allowed values. * * @param mixed $subject The value to check. * @param mixed ...$values A variable number of allowed values. * * @return bool */ public static function enum(mixed $subject, ...$values): bool { return in_array($subject, $values); } /** * Checks that the value contains at least one successfully uploaded file. * * Presence is enforced by this rule itself: if nothing was uploaded (or the * value is not a file descriptor) it fails. Make a file field optional with * the `nullable` rule. An upload that errored (size limit, partial, ...) also * fails. * * @param mixed $subject The value to check (a $_FILES entry, single or multi). * * @return bool */ public static function file(mixed $subject): bool { $uploads = static::asFile($subject); if ($uploads === null) { return false; } $present = false; foreach ($uploads as $upload) { if ($upload['error'] === UPLOAD_ERR_NO_FILE) { continue; } $present = true; if ($upload['error'] !== UPLOAD_ERR_OK || $upload['name'] === '') { return false; } } return $present; } /** * Checks that every uploaded file is an image (by its real MIME type). * * @param mixed $subject The value to check (a $_FILES entry, single or multi). * * @return bool */ public static function image(mixed $subject): bool { return static::everyUploadedMime( $subject, static fn (string $mime): bool => str_starts_with($mime, 'image/') ); } /** * Checks that every uploaded file matches one of the allowed extensions. * * The extension list is translated to the set of MIME types it stands for * (via $mimeTypes) and compared against each file's real, sniffed MIME type * - never the client-declared type nor the file name. * * @param mixed $subject The value to check (a $_FILES entry, single or multi). * @param string ...$extensions Allowed extensions, e.g. "jpg", "png". * * @return bool */ public static function mimes(mixed $subject, ...$extensions): bool { $allowed = []; foreach ($extensions as $extension) { foreach (static::$mimeTypes[strtolower(trim($extension))] ?? [] as $mime) { $allowed[$mime] = true; } } $allowedTypes = array_keys($allowed); return static::everyUploadedMime( $subject, static fn (string $mime): bool => in_array($mime, $allowedTypes, true) ); } /** * File-field variant of `min`: every uploaded file must be at least $min KB. * * @param mixed $subject The value to check (a $_FILES entry, single or multi). * @param mixed $min Minimum size in kilobytes. * * @return bool */ public static function file_min(mixed $subject, mixed $min): bool { $min = (float) $min; return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb >= $min); } /** * File-field variant of `max`: every uploaded file must be at most $max KB. * * @param mixed $subject The value to check (a $_FILES entry, single or multi). * @param mixed $max Maximum size in kilobytes. * * @return bool */ public static function file_max(mixed $subject, mixed $max): bool { $max = (float) $max; return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb <= $max); } /** * File-field variant of `between`: every uploaded file must be between $min * and $max kilobytes. * * @param mixed $subject The value to check (a $_FILES entry, single or multi). * @param mixed $min Lower bound in KB. * @param mixed $max Upper bound in KB. * * @return bool */ public static function file_between(mixed $subject, mixed $min, mixed $max): bool { $min = (float) $min; $max = (float) $max; return static::everyUploadedSize( $subject, static fn (float $kb): bool => $kb >= $min && $kb <= $max ); } /** * File-field variant of `size`: every uploaded file must be exactly $size KB. * * @param mixed $subject The value to check (a $_FILES entry, single or multi). * @param mixed $size Exact size in kilobytes. * * @return bool */ public static function file_size(mixed $subject, mixed $size): bool { $size = (float) $size; return static::everyUploadedSize($subject, static fn (float $kb): bool => $kb === $size); } /** * File-field variant of `required`: at least one file must be uploaded. * * @param mixed $subject The value to check (a $_FILES entry, single or multi). * * @return bool */ public static function file_required(mixed $subject): bool { return static::hasUploadedFile($subject); } /** * File-field marker for `nullable`. * * As a rule it always passes; the skip decision for a file field lives in * validateList(), which treats a file field as empty when no upload came in * (see hasUploadedFile()). * * @param mixed $subject The value to check. * * @return bool Always true. */ public static function file_nullable(mixed $subject): bool { return true; } /** * Reads a field from a data set that may be a Neuron object or an array. * * Returns null when the field is not defined. Neuron already yields null * for undefined properties, so no notice is raised for either source. * * @param mixed $haystack The data set (Neuron or array). * @param string $field The field name to read. * * @return mixed */ private static function readField(mixed $haystack, string $field): mixed { if (is_array($haystack)) { return $haystack[$field] ?? null; } if (is_object($haystack)) { return $haystack->{$field}; } return null; } /** * Determines whether a value counts as "empty" for optionality rules * (nullable / required_with / required_if). * * Matches the conventional definition: null, an empty string or an empty * array. Note that, unlike PHP's empty(), "0" and 0 are NOT treated as * empty so they cannot silently bypass a required-style rule. File fields * have their own emptiness predicate (hasUploadedFile) used by the file * routing in validateList(); this helper stays scalar. * * @param mixed $value The value to test. * * @return bool */ private static function isEmpty(mixed $value): bool { return $value === null || $value === '' || $value === []; } /** * Detects and normalizes a file upload value into a list of descriptors. * * A value is treated as an uploaded file when it is an array with the shape * of a $_FILES entry, i.e. it defines at least the `name`, `tmp_name` and * `error` keys. Both single uploads and multi-file uploads (input name="x[]", * where every key is an array) are supported. Each returned descriptor always * carries name, type, tmp_name, error and size with safe defaults, so the * file rules can rely on every key. Non-file values return null. * * @param mixed $subject The value to inspect. * * @return array|null A list of normalized descriptors, or null when not a file. */ private static function asFile(mixed $subject): ?array { if ( !is_array($subject) || !isset($subject['name'], $subject['tmp_name'], $subject['error']) ) { return null; } if (is_array($subject['name'])) { return static::asFileList($subject); } return [[ 'name' => (string) $subject['name'], 'type' => (string) ($subject['type'] ?? ''), 'tmp_name' => (string) $subject['tmp_name'], 'error' => (int) $subject['error'], 'size' => (int) ($subject['size'] ?? 0), ]]; } /** * Zips a multi-file $_FILES entry (parallel arrays) into a list of single * file descriptors. The number of files is taken from the `name` array and * the other arrays are read index by index with safe defaults. * * @param array $subject The raw multi-file $_FILES entry. * * @return array The list of normalized descriptors. */ private static function asFileList(array $subject): array { $names = array_values($subject['name']); $read = static function ($key, int $index) { $values = is_array($key) ? array_values($key) : []; return $values[$index] ?? null; }; $files = []; foreach ($names as $index => $name) { $files[] = [ 'name' => (string) $name, 'type' => (string) ($read($subject['type'] ?? [], $index) ?? ''), 'tmp_name' => (string) ($read($subject['tmp_name'], $index) ?? ''), 'error' => (int) ($read($subject['error'], $index) ?? UPLOAD_ERR_NO_FILE), 'size' => (int) ($read($subject['size'] ?? [], $index) ?? 0), ]; } return $files; } /** * Determines the real MIME type of an uploaded file by sniffing its content * with finfo, ignoring the client-declared `type` and the file `name`. * * Returns null when the file cannot be inspected (empty/missing path or the * fileinfo extension is unavailable). * * @param array $descriptor A normalized file descriptor from asFile(). * * @return string|null */ private static function realMime(array $descriptor): ?string { $path = $descriptor['tmp_name'] ?? ''; if ($path === '' || !is_file($path) || !class_exists(\finfo::class)) { return null; } $finfo = new \finfo(FILEINFO_MIME_TYPE); $mime = $finfo->file($path); return $mime === false ? null : $mime; } /** * Applies a MIME predicate to every uploaded file of a value. * * Presence is enforced: fails when the value is not a file descriptor, when * no upload was actually provided, when a present upload errored, or when a * present upload's real (sniffed) MIME type is unknown or does not satisfy * $check. Make a file field optional with the `nullable` rule. * * @param mixed $subject The value to inspect. * @param callable $check Predicate receiving the real MIME type: fn(string): bool. * * @return bool */ private static function everyUploadedMime(mixed $subject, callable $check): bool { $uploads = static::asFile($subject); if ($uploads === null) { return false; } $present = false; foreach ($uploads as $upload) { if ($upload['error'] === UPLOAD_ERR_NO_FILE) { continue; } $present = true; if ($upload['error'] !== UPLOAD_ERR_OK || $upload['name'] === '') { return false; } $mime = static::realMime($upload); if ($mime === null || $check($mime) === false) { return false; } } return $present; } /** * Sizes (in KB) of the present uploads of a value, or null when it is not a * file. Absent uploads are skipped, so a field with no file yields an empty * list. Division is forced to float so exact-KB sizes compare cleanly. * * @param mixed $subject The value to measure. * * @return float[]|null */ private static function fileSizesKb(mixed $subject): ?array { $uploads = static::asFile($subject); if ($uploads === null) { return null; } $sizes = []; foreach ($uploads as $upload) { if ($upload['error'] === UPLOAD_ERR_NO_FILE || $upload['name'] === '') { continue; } $sizes[] = (float) ($upload['size'] / 1024); } return $sizes; } /** * Checks that the value is a file and that every present upload satisfies * $check. Fails when the value is not a file descriptor; an empty set of * present uploads passes (presence belongs to the file rules). * * @param mixed $subject The value to inspect. * @param callable $check Predicate receiving a size in KB: fn(float): bool. * * @return bool */ private static function everyUploadedSize(mixed $subject, callable $check): bool { $sizes = static::fileSizesKb($subject); if ($sizes === null) { return false; } foreach ($sizes as $kb) { if ($check($kb) === false) { return false; } } return true; } /** * Checks whether a value carries at least one successfully uploaded file. * * @param mixed $subject The value to inspect. * * @return bool */ private static function hasUploadedFile(mixed $subject): bool { $uploads = static::asFile($subject); if ($uploads === null) { return false; } foreach ($uploads as $upload) { if ($upload['error'] !== UPLOAD_ERR_NO_FILE && $upload['name'] !== '') { return true; } } return false; } }