255 || preg_match('/^\s*$/u', $title) || str_contains($title, "\0") ) { throw new InvalidArgumentException('Model name must contain 1 to 255 characters and not be blank or NUL.'); } } public static function validateShape(array $metadata): void { if (count($metadata) !== 3 || !isset($metadata['title']) || !is_string($metadata['title'])) { throw new InvalidArgumentException('Metadata requires exactly title, notes and sourceURL.'); } foreach (['notes', 'sourceURL'] as $field) { if ( !array_key_exists($field, $metadata) || ($metadata[$field] !== null && !is_string($metadata[$field])) ) { throw new InvalidArgumentException('Metadata requires nullable string notes and sourceURL.'); } } } /** Validate/normalize changed fields only; expected is always a raw snapshot. */ public static function replacement(array $expected, array $replacement): array { self::validateShape($expected); self::validateShape($replacement); $result = []; foreach (['title', 'notes', 'sourceURL'] as $field) { $value = $replacement[$field]; if ($value !== $expected[$field]) { if ($field === 'title') { self::validateTitle($value); } elseif ($value === '' || $value === null) { $value = null; } elseif ($field === 'notes') { if (preg_match('//u', $value) !== 1 || str_contains($value, "\0")) { throw new InvalidArgumentException('Notes must be valid UTF-8 without NUL.'); } } else { self::validateUrl($value); } } $result[$field] = $value; } return $result; } private static function validateUrl(string $url): void { $parts = parse_url($url); if ( strlen($url) > 2048 || preg_match('/[^\x21-\x7e]|\\\\|%(?![0-9a-fA-F]{2})/', $url) || $parts === false || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) || empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || filter_var($url, FILTER_VALIDATE_URL) === false ) { throw new InvalidArgumentException('Source URL must be an absolute ASCII HTTP(S) URL without userinfo.'); } } }