From fdf058a91e537d5a6098ac392e9c0048dec244a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 21 Oct 2026 14:02:30 -0300 Subject: [PATCH] fix(headers): preserve PHP 8.3 compatibility without mbstring --- .changelog/php83-header-runtime.md | 6 ++ .../skills/fast-forward-http-message/SKILL.md | 2 +- src/Header/Accept.php | 2 +- src/Header/Authorization.php | 12 ++-- src/Header/ContentEncoding.php | 8 +-- src/Header/ContentType.php | 4 +- src/Header/TransferEncoding.php | 5 +- tests/HeaderRuntimeCompatibilityTest.php | 31 +++++++++ tests/fixtures/header-runtime.php | 65 +++++++++++++++++++ 9 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 .changelog/php83-header-runtime.md create mode 100644 tests/HeaderRuntimeCompatibilityTest.php create mode 100644 tests/fixtures/header-runtime.php diff --git a/.changelog/php83-header-runtime.md b/.changelog/php83-header-runtime.md new file mode 100644 index 0000000..8aead88 --- /dev/null +++ b/.changelog/php83-header-runtime.md @@ -0,0 +1,6 @@ +--- +category: fixed +type: patch +--- + +Keep header helpers compatible with PHP 8.3 without mbstring or development polyfills, using ASCII HTTP whitespace and token case folding while preserving credential values. diff --git a/resources/skills/fast-forward-http-message/SKILL.md b/resources/skills/fast-forward-http-message/SKILL.md index cad7ff4..759b75e 100644 --- a/resources/skills/fast-forward-http-message/SKILL.md +++ b/resources/skills/fast-forward-http-message/SKILL.md @@ -61,7 +61,7 @@ This library uses `FastForward\Http\Message`; no container/provider is required. JsonResponse sets an application/json Content-Type with charset and uses JsonStream. withPayload returns a new stream/response, but PSR-7 streams can themselves be mutable: do not treat a shared stream's cursor or write() as an immutable payload update. Top-level resources raise InvalidArgumentException; malformed UTF-8 and other JSON encoding failures raise JsonException. Keep the original response when testing mutation boundaries. -TextResponse and HtmlResponse set their corresponding content types, EmptyResponse is 204, RedirectResponse uses 302 or 301 for its permanent flag. RequestMethod/StatusCode and Header helpers are independent utilities. Authorization::parse returns null for missing/unknown or malformed supported credentials; parsing is not authentication. Never log credentials. Digest/AWS parsing uses mb_trim, which is native only from PHP 8.4: on PHP 8.3 install an appropriate polyfill before using those optional routes. The core JSON example needs no such route. +TextResponse and HtmlResponse set their corresponding content types, EmptyResponse is 204, RedirectResponse uses 302 or 301 for its permanent flag. RequestMethod/StatusCode and Header helpers are independent utilities. Authorization::parse returns null for missing/unknown or malformed supported credentials; parsing is not authentication. Never log credentials. Header parsing uses ASCII HTTP whitespace (SP and HTAB) and token case folding; no mbstring extension or PHP 8.4 polyfill is required. Credential values retain their original bytes. Older installed versions may still call mb_trim; inspect their source before using those routes on PHP 8.3. ## Verify integration diff --git a/src/Header/Accept.php b/src/Header/Accept.php index ad33a73..610c31b 100644 --- a/src/Header/Accept.php +++ b/src/Header/Accept.php @@ -90,7 +90,7 @@ private static function parseHeader(string $header): array if (preg_match_all($pattern, $header, $matches, \PREG_SET_ORDER)) { foreach ($matches as $match) { - $type = mb_trim($match['type']); + $type = trim($match['type'], " \t"); $q = isset($match['q']) && '' !== $match['q'] ? (float) $match['q'] : 1.0; $preferences[] = [ diff --git a/src/Header/Authorization.php b/src/Header/Authorization.php index df1b409..804dbac 100644 --- a/src/Header/Authorization.php +++ b/src/Header/Authorization.php @@ -268,7 +268,7 @@ private static function parseDigest(string $credentials): ?DigestCredential $parts = explode(',', $credentials); foreach ($parts as $part) { - $part = mb_trim($part); + $part = trim($part, " \t"); $pattern = '/^(?[a-zA-Z0-9_-]+)=(?"[^"]*"|[^"]*)$/i'; @@ -276,8 +276,8 @@ private static function parseDigest(string $credentials): ?DigestCredential return null; } - $key = mb_strtolower($match['key']); - $value = mb_trim($match['value'], '"'); + $key = strtolower($match['key']); + $value = trim($match['value'], '"'); $params[$key] = $value; } @@ -323,7 +323,7 @@ private static function parseAws(string $credentials): ?AwsCredential $parts = explode(',', $credentials); foreach ($parts as $part) { - $part = mb_trim($part); + $part = trim($part, " \t"); $pattern = '/^(?[a-zA-Z0-9_-]+)=(?[^, ]+)$/'; @@ -331,8 +331,8 @@ private static function parseAws(string $credentials): ?AwsCredential return null; } - $key = mb_trim($match['key']); - $value = mb_trim($match['value']); + $key = trim($match['key'], " \t"); + $value = trim($match['value'], " \t"); $params[$key] = $value; } diff --git a/src/Header/ContentEncoding.php b/src/Header/ContentEncoding.php index 2b39958..b8c56b7 100644 --- a/src/Header/ContentEncoding.php +++ b/src/Header/ContentEncoding.php @@ -128,13 +128,13 @@ public static function isSupported(self $encoding, string $acceptEncodingHeader) if (preg_match_all($pattern, $acceptEncodingHeader, $matches, \PREG_SET_ORDER)) { foreach ($matches as $match) { - $name = mb_trim($match['name']); - $q = isset($match['q']) && '' !== $match['q'] ? (float) $match['q'] : 1.0; - $preferences[mb_strtolower($name)] = $q; + $name = trim($match['name'], " \t"); + $q = isset($match['q']) && '' !== $match['q'] ? (float) $match['q'] : 1.0; + $preferences[strtolower($name)] = $q; } } - $encodingName = mb_strtolower($encoding->value); + $encodingName = strtolower($encoding->value); $aliases = self::getAliases($encoding); $checkNames = [$encodingName, ...$aliases]; diff --git a/src/Header/ContentType.php b/src/Header/ContentType.php index 3e30470..b55c5a4 100644 --- a/src/Header/ContentType.php +++ b/src/Header/ContentType.php @@ -84,7 +84,7 @@ public static function fromHeaderString(string $header): ?self * * This method SHOULD be used when charset negotiation or validation is * required. If no charset is present, this method MUST return null. The - * extracted charset value SHALL be trimmed of surrounding whitespace. + * extracted charset value SHALL be trimmed of surrounding HTTP whitespace (SP and HTAB). * * @param string $contentTypeHeader the full Content-Type header value * @@ -93,7 +93,7 @@ public static function fromHeaderString(string $header): ?self public static function getCharset(string $contentTypeHeader): ?string { if (preg_match('/charset=([^;]+)/i', $contentTypeHeader, $matches)) { - return mb_trim($matches[1]); + return trim($matches[1], " \t"); } return null; diff --git a/src/Header/TransferEncoding.php b/src/Header/TransferEncoding.php index 10e71ab..fad92d7 100644 --- a/src/Header/TransferEncoding.php +++ b/src/Header/TransferEncoding.php @@ -112,7 +112,10 @@ public static function isChunked(string $transferEncodingHeader): bool return false; } - $codings = array_map(\mb_trim(...), explode(',', mb_strtolower($transferEncodingHeader))); + $codings = array_map( + static fn (string $coding): string => trim($coding, " \t"), + explode(',', strtolower($transferEncodingHeader)), + ); return \in_array(self::Chunked->value, $codings, true); } diff --git a/tests/HeaderRuntimeCompatibilityTest.php b/tests/HeaderRuntimeCompatibilityTest.php new file mode 100644 index 0000000..ea9bf19 --- /dev/null +++ b/tests/HeaderRuntimeCompatibilityTest.php @@ -0,0 +1,31 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + self::assertIsResource($process); + + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + + self::assertSame(0, proc_close($process), $errors . $output); + self::assertStringContainsString('AWS: ok', $output); + } +} diff --git a/tests/fixtures/header-runtime.php b/tests/fixtures/header-runtime.php new file mode 100644 index 0000000..6379c42 --- /dev/null +++ b/tests/fixtures/header-runtime.php @@ -0,0 +1,65 @@ + static fn (): bool => Accept::ApplicationJson === Accept::getBestMatch(" \tapplication/json\t ", [Accept::ApplicationJson]), + 'ContentType' => static fn (): bool => 'UTF-8' === ContentType::getCharset("text/plain; charset= \tUTF-8\t "), + 'ContentEncoding' => static fn (): bool => ContentEncoding::isSupported(ContentEncoding::Gzip, 'GZIP;q=1') + && ! ContentEncoding::isSupported(ContentEncoding::Gzip, 'GZIP;q=0'), + 'TransferEncoding' => static fn (): bool => TransferEncoding::isChunked("GZIP, \tCHUNKED\t "), + 'Digest' => static function (): bool { + $value = "\u{00A0}Élodie\xFF\u{00A0}"; + $credential = Authorization::parse('Digest USERNAME="' . $value . '", realm="Example", nonce="ABC", uri="/Path", response="ABC", qop="auth", nc="00000001", cnonce="XYZ"'); + return $credential instanceof DigestCredential && $value === $credential->username + && 'Example' === $credential->realm && 'ABC' === $credential->nonce && '/Path' === $credential->uri; + }, + 'AWS' => static function (): bool { + $scope = "KEY\u{00A0}/20261021/Region/Service/aws4_request"; + $signature = str_repeat('Ab', 32); + $credential = Authorization::parse('AWS4-HMAC-SHA256 Credential=' . $scope . ', SignedHeaders=host;x-test, Signature=' . $signature); + return $credential instanceof AwsCredential && $scope === $credential->credentialScope + && $signature === $credential->signature && 'host;x-test' === $credential->signedHeaders; + }, + 'Unicode is not OWS' => static fn (): bool => null === Accept::getBestMatch("\u{00A0}application/json\u{00A0}", [Accept::ApplicationJson]) + && ! TransferEncoding::isChunked("\u{00A0}chunked\u{00A0}") + && "\u{00A0}UTF-8\u{00A0}" === ContentType::getCharset("text/plain; charset=\u{00A0}UTF-8\u{00A0}"), +]; + +$failed = false; +foreach ($checks as $name => $check) { + try { + if (! $check()) { + throw new RuntimeException('Unexpected result'); + } + echo $name . ": ok\n"; + } catch (Throwable $error) { + // Fixtures contain synthetic data only; never print credential values. + fwrite(STDERR, $name . ': ' . $error->getMessage() . "\n"); + $failed = true; + } +} +exit($failed ? 1 : 0);