diff --git a/AcceptHeader.php b/AcceptHeader.php index 853c000e00f..e735c75647b 100644 --- a/AcceptHeader.php +++ b/AcceptHeader.php @@ -25,7 +25,7 @@ class_exists(AcceptHeaderItem::class); class AcceptHeader { /** - * @var AcceptHeaderItem[] + * @var array */ private array $items = []; @@ -46,18 +46,15 @@ public function __construct(array $items) */ public static function fromString(?string $headerValue): self { - $parts = HeaderUtils::split($headerValue ?? '', ',;='); + $items = []; + foreach (HeaderUtils::split($headerValue ?? '', ',;=') as $i => $parts) { + $part = array_shift($parts); + $item = new AcceptHeaderItem($part[0], HeaderUtils::combine($parts)); - return new self(array_map(function ($subParts) { - static $index = 0; - $part = array_shift($subParts); - $attributes = HeaderUtils::combine($subParts); - - $item = new AcceptHeaderItem($part[0], $attributes); - $item->setIndex($index++); + $items[] = $item->setIndex($i); + } - return $item; - }, $parts)); + return new self($items); } /** @@ -73,7 +70,9 @@ public function __toString(): string */ public function has(string $value): bool { - return isset($this->items[$value]); + $canonicalKey = $this->getCanonicalKey(AcceptHeaderItem::fromString($value)); + + return isset($this->items[$canonicalKey]); } /** @@ -81,7 +80,26 @@ public function has(string $value): bool */ public function get(string $value): ?AcceptHeaderItem { - return $this->items[$value] ?? $this->items[explode('/', $value)[0].'/*'] ?? $this->items['*/*'] ?? $this->items['*'] ?? null; + $queryItem = AcceptHeaderItem::fromString($value.';q=1'); + $canonicalKey = $this->getCanonicalKey($queryItem); + + if (isset($this->items[$canonicalKey])) { + return $this->items[$canonicalKey]; + } + + // Collect and filter matching candidates + if (!$candidates = array_filter($this->items, fn (AcceptHeaderItem $item) => $this->matches($item, $queryItem))) { + return null; + } + + usort( + $candidates, + fn ($a, $b) => $this->getSpecificity($b, $queryItem) <=> $this->getSpecificity($a, $queryItem) // Descending specificity + ?: $b->getQuality() <=> $a->getQuality() // Descending quality + ?: $a->getIndex() <=> $b->getIndex() // Ascending index (stability) + ); + + return reset($candidates); } /** @@ -91,7 +109,7 @@ public function get(string $value): ?AcceptHeaderItem */ public function add(AcceptHeaderItem $item): static { - $this->items[$item->getValue()] = $item; + $this->items[$this->getCanonicalKey($item)] = $item; $this->sorted = false; return $this; @@ -114,7 +132,7 @@ public function all(): array */ public function filter(string $pattern): self { - return new self(array_filter($this->items, fn (AcceptHeaderItem $item) => preg_match($pattern, $item->getValue()))); + return new self(array_filter($this->items, static fn ($item) => preg_match($pattern, $item->getValue()))); } /** @@ -133,18 +151,154 @@ public function first(): ?AcceptHeaderItem private function sort(): void { if (!$this->sorted) { - uasort($this->items, function (AcceptHeaderItem $a, AcceptHeaderItem $b) { - $qA = $a->getQuality(); - $qB = $b->getQuality(); + uasort($this->items, static fn ($a, $b) => $b->getQuality() <=> $a->getQuality() ?: $a->getIndex() <=> $b->getIndex()); - if ($qA === $qB) { - return $a->getIndex() > $b->getIndex() ? 1 : -1; - } + $this->sorted = true; + } + } - return $qA > $qB ? -1 : 1; - }); + /** + * Generates the canonical key for storing/retrieving an item. + */ + private function getCanonicalKey(AcceptHeaderItem $item): string + { + $parts = []; - $this->sorted = true; + // Normalize and sort attributes for consistent key generation + $attributes = $this->getMediaParams($item); + ksort($attributes); + + foreach ($attributes as $name => $value) { + if (null === $value) { + $parts[] = $name; // Flag parameter (e.g., "flowed") + continue; + } + + // Quote values containing spaces, commas, semicolons, or equals per RFC 9110 + // This handles cases like 'format="value with space"' or similar. + $quotedValue = \is_string($value) && preg_match('/[\s;,=]/', $value) ? '"'.addcslashes($value, '"\\').'"' : $value; + + $parts[] = $name.'='.$quotedValue; + } + + return $item->getValue().($parts ? ';'.implode(';', $parts) : ''); + } + + /** + * Checks if a given header item (range) matches a queried item (value). + * + * @param AcceptHeaderItem $rangeItem The item from the Accept header (e.g., text/*;format=flowed) + * @param AcceptHeaderItem $queryItem The item being queried (e.g., text/plain;format=flowed;charset=utf-8) + */ + private function matches(AcceptHeaderItem $rangeItem, AcceptHeaderItem $queryItem): bool + { + $rangeValue = strtolower($rangeItem->getValue()); + $queryValue = strtolower($queryItem->getValue()); + + // Handle universal wildcard ranges + if ('*' === $rangeValue || '*/*' === $rangeValue) { + return $this->rangeParametersMatch($rangeItem, $queryItem); + } + + // Queries for '*' only match wildcard ranges (handled above) + if ('*' === $queryValue) { + return false; } + + // Ensure media vs. non-media consistency + $isQueryMedia = str_contains($queryValue, '/'); + $isRangeMedia = str_contains($rangeValue, '/'); + + if ($isQueryMedia !== $isRangeMedia) { + return false; + } + + // Non-media: exact match only (wildcards handled above) + if (!$isQueryMedia) { + return $rangeValue === $queryValue && $this->rangeParametersMatch($rangeItem, $queryItem); + } + + // Media type: type/subtype with wildcards + [$queryType, $querySubtype] = explode('/', $queryValue, 2); + [$rangeType, $rangeSubtype] = explode('/', $rangeValue, 2) + [1 => '*']; + + if ('*' !== $rangeType && $rangeType !== $queryType) { + return false; + } + + if ('*' !== $rangeSubtype && $rangeSubtype !== $querySubtype) { + return false; + } + + // Parameters must match + return $this->rangeParametersMatch($rangeItem, $queryItem); + } + + /** + * Checks if the parameters of a range item are satisfied by the query item. + * + * Parameters are case-insensitive; range params must be a subset of query params. + */ + private function rangeParametersMatch(AcceptHeaderItem $rangeItem, AcceptHeaderItem $queryItem): bool + { + $queryAttributes = $this->getMediaParams($queryItem); + $rangeAttributes = $this->getMediaParams($rangeItem); + + foreach ($rangeAttributes as $name => $rangeValue) { + if (!\array_key_exists($name, $queryAttributes)) { + return false; // Missing required param + } + + $queryValue = $queryAttributes[$name]; + + if (null === $rangeValue) { + return null === $queryValue; // Both flags or neither + } + + if (null === $queryValue || strtolower($queryValue) !== strtolower($rangeValue)) { + return false; + } + } + + return true; + } + + /** + * Calculates a specificity score for sorting: media precision + param count. + */ + private function getSpecificity(AcceptHeaderItem $item, AcceptHeaderItem $queryItem): int + { + $rangeValue = strtolower($item->getValue()); + $queryValue = strtolower($queryItem->getValue()); + + $paramCount = \count($this->getMediaParams($item)); + + $isQueryMedia = str_contains($queryValue, '/'); + $isRangeMedia = str_contains($rangeValue, '/'); + + if (!$isQueryMedia && !$isRangeMedia) { + return ('*' !== $rangeValue ? 2000 : 1000) + $paramCount; + } + + [$rangeType, $rangeSubtype] = explode('/', $rangeValue, 2) + [1 => '*']; + + $specificity = match (true) { + '*' !== $rangeSubtype => 3000, // Exact subtype (text/plain) + '*' !== $rangeType => 2000, // Type wildcard (text/*) + default => 1000, // Full wildcard (*/* or *) + }; + + return $specificity + $paramCount; + } + + /** + * Returns normalized attributes: keys lowercased, excluding 'q'. + */ + private function getMediaParams(AcceptHeaderItem $item): array + { + $attributes = array_change_key_case($item->getAttributes(), \CASE_LOWER); + unset($attributes['q']); + + return $attributes; } } diff --git a/Request.php b/Request.php index c94a2a181f0..a8b21d7689c 100644 --- a/Request.php +++ b/Request.php @@ -339,10 +339,21 @@ public static function create(string $uri, string $method = 'GET', array $parame $server['PATH_INFO'] = ''; $server['REQUEST_METHOD'] = strtoupper($method); + if (($i = strcspn($uri, ':/?#')) && ':' === ($uri[$i] ?? null) && (strspn($uri, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+-.') !== $i || strcspn($uri, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'))) { + throw new BadRequestException('Invalid URI: Scheme is malformed.'); + } if (false === $components = parse_url(\strlen($uri) !== strcspn($uri, '?#') ? $uri : $uri.'#')) { throw new BadRequestException('Invalid URI.'); } + $part = ($components['user'] ?? '').':'.($components['pass'] ?? ''); + + if (':' !== $part && \strlen($part) !== strcspn($part, '[]')) { + throw new BadRequestException('Invalid URI: Userinfo is malformed.'); + } + if (($part = $components['host'] ?? '') && !self::isHostValid($part)) { + throw new BadRequestException('Invalid URI: Host is malformed.'); + } if (false !== ($i = strpos($uri, '\\')) && $i < strcspn($uri, '?#')) { throw new BadRequestException('Invalid URI: A URI cannot contain a backslash.'); } @@ -1129,10 +1140,8 @@ public function getHost(): string // host is lowercase as per RFC 952/2181 $host = strtolower(preg_replace('/:\d+$/', '', trim($host))); - // as the host can come from the user (HTTP_HOST and depending on the configuration, SERVER_NAME too can come from the user) - // check that it does not contain forbidden characters (see RFC 952 and RFC 2181) - // use preg_replace() instead of preg_match() to prevent DoS attacks with long host names - if ($host && '' !== preg_replace('/(?:^\[)?[a-zA-Z0-9-:\]_]+\.?/', '', $host)) { + // the host can come from the user (HTTP_HOST and depending on the configuration, SERVER_NAME too can come from the user) + if ($host && !self::isHostValid($host)) { if (!$this->isHostValid) { return ''; } @@ -1974,9 +1983,8 @@ protected function preparePathInfo(): string } $pathInfo = substr($requestUri, \strlen($baseUrl)); - if ('' === $pathInfo) { - // If substr() returns false then PATH_INFO is set to an empty string - return '/'; + if ('' === $pathInfo || '/' !== $pathInfo[0]) { + return '/'.$pathInfo; } return $pathInfo; @@ -2191,4 +2199,21 @@ private function isIisRewrite(): bool return $this->isIisRewrite; } + + /** + * See https://url.spec.whatwg.org/. + */ + private static function isHostValid(string $host): bool + { + if ('[' === $host[0]) { + return ']' === $host[-1] && filter_var(substr($host, 1, -1), \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6); + } + + if (preg_match('/\.[0-9]++\.?$/D', $host)) { + return null !== filter_var($host, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4 | \FILTER_NULL_ON_FAILURE); + } + + // use preg_replace() instead of preg_match() to prevent DoS attacks with long host names + return '' === preg_replace('/[-a-zA-Z0-9_]++\.?/', '', $host); + } } diff --git a/Tests/AcceptHeaderTest.php b/Tests/AcceptHeaderTest.php index f38f1babf82..ada370250f4 100644 --- a/Tests/AcceptHeaderTest.php +++ b/Tests/AcceptHeaderTest.php @@ -93,6 +93,8 @@ public static function provideSortingData() 'quality has priority' => ['*;q=0.3,ISO-8859-1,utf-8;q=0.7', ['ISO-8859-1', 'utf-8', '*']], 'order matters when q is equal' => ['*;q=0.3,ISO-8859-1;q=0.7,utf-8;q=0.7', ['ISO-8859-1', 'utf-8', '*']], 'order matters when q is equal2' => ['*;q=0.3,utf-8;q=0.7,ISO-8859-1;q=0.7', ['utf-8', 'ISO-8859-1', '*']], + 'additional attributes like "format" should be handled according RFC 9110' => ['text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5', ['text/plain;format=flowed', 'text/plain', '*/*', 'text/plain;format=fixed', 'text/*']], + 'additional attributes like "format" should be handled according obsoleted RFC 7231 as well' => ['text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5', ['text/html;level=1', 'text/html', '*/*', 'text/html;level=2', 'text/*']], ]; } @@ -100,7 +102,7 @@ public static function provideSortingData() public function testDefaultValue($acceptHeader, $value, $expectedQuality) { $header = AcceptHeader::fromString($acceptHeader); - $this->assertSame($expectedQuality, $header->get($value)->getQuality()); + $this->assertSame($expectedQuality, $header->get($value)?->getQuality()); } public static function provideDefaultValueData() @@ -119,5 +121,50 @@ public static function provideDefaultValueData() yield ['*;q=0.3, ISO-8859-1;q=0.7, utf-8;q=0.7', '*', 0.3]; yield ['*;q=0.3, ISO-8859-1;q=0.7, utf-8;q=0.7', 'utf-8', 0.7]; yield ['*;q=0.3, ISO-8859-1;q=0.7, utf-8;q=0.7', 'SHIFT_JIS', 0.3]; + yield 'additional attributes like "format" should be handled according RFC 9110' => ['text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5', 'text/plain;format=flowed', 1.0]; + yield 'additional attributes like "format" should be handled according obsoleted RFC 7231 as well' => ['text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5', 'text/html;level=1', 1.0]; + + // Edge cases for case-insensitivity + yield 'case-insensitive param names' => ['text/plain;format=flowed;q=0.8, text/plain;Format=fixed', 'text/plain;format=fixed', 1.0]; + yield 'case-insensitive charset' => ['text/plain;Charset=utf-8', 'text/plain;charset=utf-8', 1.0]; + + // Quoted values and specials + yield 'quoted value with space' => ['text/plain;param="value with space"', 'text/plain;param="value with space"', 1.0]; + yield 'quoted value with backslash' => ['text/plain;param="value\\with\\backslash"', 'text/plain;param="value\\with\\backslash"', 1.0]; + yield 'mismatched quoted' => ['text/plain;param="value with space"', 'text/plain;param=value with space', 1.0]; + + // Flag params or empty + yield 'flag param' => ['text/plain;flowed;q=0.9', 'text/plain;flowed', 0.9]; + yield 'empty param value' => ['text/plain;param=', 'text/plain;param=""', 1.0]; + yield 'missing required flag' => ['text/plain;flowed', 'text/plain', null]; + + // Extra params in query + yield 'extra param in query' => ['text/plain;format=flowed', 'text/plain;format=flowed;charset=utf-8', 1.0]; + yield 'missing required param in query' => ['text/plain;format=flowed', 'text/plain;charset=utf-8', null]; + yield 'wildcard with param' => ['text/*;format=flowed', 'text/plain;format=flowed', 1.0]; + yield 'wildcard missing param' => ['text/*;format=flowed', 'text/plain', null]; + + // Wildcards and specificity + yield 'specificity priority' => ['*/*;q=0.1, text/*;format=flowed;q=0.5, text/plain;q=0.8', 'text/plain;format=flowed', 0.8]; + yield 'wildcard with param match' => ['*/*;param=test', 'text/plain;param=test', 1.0]; + yield 'wildcard with param no match' => ['*/*;param=test', 'text/plain', null]; + + // Non-media types + yield 'charset wildcard' => ['utf-8;q=0.9, *;q=0.5', 'iso-8859-1', 0.5]; + yield 'language star' => ['*;q=0.5', 'en-US', 0.5]; + yield 'non-media */*' => ['*/*;q=0.5', 'utf-8', 0.5]; + + // Ties and duplicates + yield 'duplicate params tie on index' => ['text/plain;format=flowed;q=0.8, text/plain;format=flowed;q=0.8', 'text/plain;format=flowed', 0.8]; + yield 'param count tie' => ['text/plain;q=0.5, text/plain;format=flowed;q=0.5', 'text/plain;format=flowed;extra=foo', 0.5]; + + // Invalid/malformed + yield 'non-media invalid' => ['text', 'text', 1.0]; + yield 'invalid subtype' => ['text/', 'text/plain', null]; + yield 'empty header' => ['', 'text/plain', null]; + + // Mixed case types + yield 'mixed case type' => ['Text/Plain;Format=flowed', 'text/plain;format=flowed', 1.0]; + yield 'mixed case charset' => ['UTF-8;q=0.9', 'utf-8', 0.9]; } } diff --git a/Tests/Fixtures/response-functional/invalid_cookie_name.php b/Tests/Fixtures/response-functional/invalid_cookie_name.php index 3acf86039d9..15fab1492fd 100644 --- a/Tests/Fixtures/response-functional/invalid_cookie_name.php +++ b/Tests/Fixtures/response-functional/invalid_cookie_name.php @@ -6,6 +6,6 @@ try { $r->headers->setCookie(new Cookie('Hello + world', 'hodor', 0, null, null, null, false, true)); -} catch (\InvalidArgumentException $e) { +} catch (InvalidArgumentException $e) { echo $e->getMessage(); } diff --git a/Tests/RequestTest.php b/Tests/RequestTest.php index 2bab913222c..0dbb73a1fdf 100644 --- a/Tests/RequestTest.php +++ b/Tests/RequestTest.php @@ -31,6 +31,8 @@ protected function tearDown(): void Request::setTrustedProxies([], -1); Request::setTrustedHosts([]); Request::setAllowedHttpMethodOverride(null); + Request::setFactory(null); + \Closure::bind(static fn () => self::$formats = null, null, Request::class)(); } public function testInitialize() @@ -583,11 +585,11 @@ public static function getFormatToMimeTypeMapProvider() ['form', ['application/x-www-form-urlencoded', 'multipart/form-data']], ['rss', ['application/rss+xml']], ['soap', ['application/soap+xml']], - ['html', ['application/xhtml+xml']], + ['html', ['text/html', 'application/xhtml+xml']], ['problem', ['application/problem+json']], ['hal', ['application/hal+json', 'application/hal+xml']], ['jsonapi', ['application/vnd.api+json']], - ['yaml', ['application/x-yaml', 'text/yaml']], + ['yaml', ['text/yaml', 'application/x-yaml']], ['wbxml', ['application/vnd.wap.wbxml']], ]; } @@ -1911,6 +1913,16 @@ public static function getBaseUrlData() '', '/foo/api/bar', ], + [ + '/api/index.phpfoo', + [ + 'SCRIPT_FILENAME' => '/var/www/api/index.php', + 'SCRIPT_NAME' => '/api/index.php', + 'PHP_SELF' => '/api/index.php', + ], + '/api/index.php', + '/foo', + ], ]; } @@ -2254,9 +2266,9 @@ public function createRequest(): Request Request::setFactory(null); } - #[DataProvider('getLongHostNames')] - public function testVeryLongHosts($host) + public function testVeryLongHosts() { + $host = 'a'.str_repeat('.a', 40000); $start = microtime(true); $request = Request::create('/'); @@ -2297,14 +2309,6 @@ public static function getHostValidities() ]; } - public static function getLongHostNames() - { - return [ - ['a'.str_repeat('.a', 40000)], - [str_repeat(':', 101)], - ]; - } - #[DataProvider('methodIdempotentProvider')] public function testMethodIdempotent($method, $idempotent) { @@ -2677,6 +2681,324 @@ public function testReservedFlags() $this->assertNotSame(0b10000000, $value, \sprintf('The constant "%s" should not use the reserved value "0b10000000".', $constant)); } } + + #[DataProvider('provideMalformedUrls')] + public function testMalformedUrls(string $url, string $expectedException) + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage($expectedException); + + Request::create($url); + } + + public static function provideMalformedUrls(): array + { + return [ + ['http://normal.com[@vulndetector.com/', 'Invalid URI: Userinfo is malformed.'], + ['http://[normal.com@vulndetector.com/', 'Invalid URI: Userinfo is malformed.'], + ['http://normal.com@[vulndetector.com/', 'Invalid URI: Host is malformed.'], + ['http://[[normal.com@][vulndetector.com/', 'Invalid URI: Userinfo is malformed.'], + ['http://[vulndetector.com]', 'Invalid URI: Host is malformed.'], + ['http://[0:0::vulndetector.com]:80', 'Invalid URI: Host is malformed.'], + ['http://[2001:db8::vulndetector.com]', 'Invalid URI: Host is malformed.'], + ['http://[malicious.com]', 'Invalid URI: Host is malformed.'], + ['http://[evil.org]', 'Invalid URI: Host is malformed.'], + ['http://[internal.server]', 'Invalid URI: Host is malformed.'], + ['http://[192.168.1.1]', 'Invalid URI: Host is malformed.'], + ['http://192.abc.1.1', 'Invalid URI: Host is malformed.'], + ['http://[localhost]', 'Invalid URI: Host is malformed.'], + ["\x80https://example.com", 'Invalid URI: Scheme is malformed.'], + ['>https://example.com', 'Invalid URI: Scheme is malformed.'], + ["http\x0b://example.com", 'Invalid URI: Scheme is malformed.'], + ["https\x80://example.com", 'Invalid URI: Scheme is malformed.'], + ['http>://example.com', 'Invalid URI: Scheme is malformed.'], + ['0http://example.com', 'Invalid URI: Scheme is malformed.'], + ]; + } + + #[DataProvider('provideLegitimateUrls')] + public function testLegitimateUrls(string $url) + { + $request = Request::create($url); + + $this->assertInstanceOf(Request::class, $request); + } + + public static function provideLegitimateUrls(): array + { + return [ + ['http://example.com'], + ['https://example.com'], + ['http://example.com:8080'], + ['https://example.com:8443'], + ['http://user:pass@example.com'], + ['http://user:pass@example.com:8080'], + ['http://user:pass@example.com/path'], + ['http://[2001:db8::1]'], + ['http://[2001:db8::1]:8080'], + ['http://[2001:db8::1]/path'], + ['http://[::1]'], + ['http://example.com/path'], + [':path'], + ]; + } + + #[DataProvider('provideAcceptableContentTypesRfc9110')] + public function testGetAcceptableContentTypesRfc9110(string $acceptHeader, array $expectedContentTypes) + { + $request = new Request(); + $request->headers->set('Accept', $acceptHeader); + $this->assertSame($expectedContentTypes, $request->getAcceptableContentTypes()); + } + + public static function provideAcceptableContentTypesRfc9110(): iterable + { + // Basic sorting by quality + yield 'quality sorting' => [ + 'text/html;q=0.9, application/json;q=0.8, text/plain;q=1.0', + ['text/plain', 'text/html', 'application/json'], + ]; + + // Parameters with RFC9110 canonical key generation (parameters normalized to lowercase) + yield 'parameters with canonical keys' => [ + 'text/plain, text/plain;format=flowed', + ['text/plain', 'text/plain;format=flowed'], + ]; + + yield 'parameters with quality and order' => [ + 'text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed, text/plain;format=fixed;q=0.4, */*;q=0.5', + ['text/plain;format=flowed', 'text/plain', '*/*', 'text/plain;format=fixed', 'text/*'], + ]; + + yield 'multiple parameters with order' => [ + 'text/html;level=1, text/html;level=2;q=0.4, text/html;q=0.7, text/*;q=0.3', + ['text/html;level=1', 'text/html', 'text/html;level=2', 'text/*'], + ]; + + // Case insensitivity for parameters - parameter names normalized to lowercase in canonical keys + yield 'case-insensitive param names normalized' => [ + 'text/plain;format=flowed;q=0.8, text/plain;Format=fixed', + ['text/plain;format=fixed', 'text/plain;format=flowed'], + ]; + + yield 'case-insensitive charset normalized' => [ + 'text/plain;Charset=utf-8, text/plain;charset=iso-8859-1;q=0.8', + ['text/plain;charset=utf-8', 'text/plain;charset=iso-8859-1'], + ]; + + // Quoted values with spaces + yield 'quoted value with space' => [ + 'text/plain;param="value with space"', + ['text/plain;param="value with space"'], + ]; + + yield 'quoted value with special chars' => [ + 'text/plain;param="value;with=special,chars"', + ['text/plain;param="value;with=special,chars"'], + ]; + + // Wildcards with parameters + yield 'wildcard type with parameter' => [ + 'text/*;format=flowed, text/plain', + ['text/*;format=flowed', 'text/plain'], + ]; + + yield 'wildcard all with parameter' => [ + '*/*;q=0.5, text/html;q=0.9', + ['text/html', '*/*'], + ]; + + // Stability - original order when quality is equal + yield 'order preserved for equal quality' => [ + 'application/json, application/xml, text/html', + ['application/json', 'application/xml', 'text/html'], + ]; + + yield 'order preserved for equal quality with params' => [ + 'text/plain;format=flowed;q=0.8, text/plain;format=fixed;q=0.8', + ['text/plain;format=flowed', 'text/plain;format=fixed'], + ]; + + // Complex scenarios + yield 'complex with wildcards and params' => [ + 'text/html, application/xhtml+xml, application/xml;q=0.9, text/*;q=0.8, */*;q=0.7', + ['text/html', 'application/xhtml+xml', 'application/xml', 'text/*', '*/*'], + ]; + + yield 'charset parameter order' => [ + 'text/plain, text/plain;charset=utf-8', + ['text/plain', 'text/plain;charset=utf-8'], + ]; + + yield 'multiple params on same type' => [ + 'text/plain;charset=utf-8;format=flowed, text/plain;charset=utf-8, text/plain', + ['text/plain;charset=utf-8;format=flowed', 'text/plain;charset=utf-8', 'text/plain'], + ]; + + // Edge cases + yield 'single wildcard' => [ + '*/*', + ['*/*'], + ]; + + yield 'type wildcard only' => [ + 'text/*', + ['text/*'], + ]; + + yield 'mixed case media types' => [ + 'Text/HTML, Application/JSON', + ['Text/HTML', 'Application/JSON'], + ]; + } + + #[DataProvider('providePreferredFormatRfc9110')] + public function testGetPreferredFormatRfc9110(string $acceptHeader, ?string $expectedFormat, ?string $default = 'html') + { + $request = new Request(); + $request->headers->set('Accept', $acceptHeader); + $this->assertSame($expectedFormat, $request->getPreferredFormat($default)); + } + + public static function providePreferredFormatRfc9110(): iterable + { + // Basic format detection with parameters + yield 'json with charset parameter' => [ + 'application/json;charset=utf-8', + 'json', + 'html', + ]; + + yield 'xml with version parameter' => [ + 'application/xml;version=1.0', + 'xml', + 'html', + ]; + + // Quality-based preference + yield 'json preferred over xml by quality' => [ + 'application/json;q=0.9, application/xml;q=0.8', + 'json', + 'html', + ]; + + yield 'xml preferred over json by quality' => [ + 'application/xml;q=0.9, application/json;q=0.8', + 'xml', + 'html', + ]; + + // Specificity affects format selection + yield 'more specific parameter wins with equal quality' => [ + 'application/json, application/json;charset=utf-8', + 'json', + 'html', + ]; + + yield 'text/html with level parameter' => [ + 'text/html;level=1, application/json', + 'html', + null, + ]; + + // Wildcards + yield 'wildcard type matches first known format' => [ + 'text/*', + 'html', + 'html', + ]; + + yield 'wildcard all matches default' => [ + '*/*', + 'html', + 'html', + ]; + + yield 'wildcard with quality lower than specific' => [ + 'application/json;q=0.9, */*;q=0.5', + 'json', + 'html', + ]; + + // Multiple content types with RFC9110 parameter handling + yield 'complex accept with parameters' => [ + 'text/html;q=0.9, application/xhtml+xml, application/xml;q=0.8, text/*;q=0.7', + 'html', + 'html', + ]; + + yield 'json with multiple parameters' => [ + 'application/json;charset=utf-8;profile=strict, text/html', + 'json', + 'html', + ]; + + // Case sensitivity - media type case is preserved, must use lowercase for proper format matching + yield 'lowercase content type for format matching' => [ + 'application/json', + 'json', + 'html', + ]; + + yield 'lowercase with parameters for format matching' => [ + 'application/json;charset=utf-8', + 'json', + 'html', + ]; + + // Quoted parameter values + yield 'quoted parameter value' => [ + 'application/json;profile="http://example.com/schema"', + 'json', + 'html', + ]; + + // Order preservation with equal quality and specificity + yield 'first match wins with equal priority' => [ + 'application/json;q=0.9, application/xml;q=0.9', + 'json', + 'html', + ]; + + // No match scenarios + yield 'unknown content type returns default' => [ + 'application/vnd.custom+unknown', + 'html', + 'html', + ]; + + yield 'unknown with null default' => [ + 'application/vnd.custom+unknown', + null, + null, + ]; + + // Empty or malformed + yield 'empty accept header' => [ + '', + 'html', + 'html', + ]; + + // Real-world examples + yield 'browser-like accept header' => [ + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'html', + 'html', + ]; + + yield 'api client prefer json' => [ + 'application/json, text/plain, */*', + 'json', + 'html', + ]; + + yield 'rss/atom feeds' => [ + 'application/atom+xml;q=0.9, application/rss+xml;q=0.8', + 'atom', + 'html', + ]; + } } class RequestContentProxy extends Request