Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changelog/php83-header-runtime.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion resources/skills/fast-forward-http-message/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/Header/Accept.php
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
12 changes: 6 additions & 6 deletions src/Header/Authorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -268,16 +268,16 @@ private static function parseDigest(string $credentials): ?DigestCredential
$parts = explode(',', $credentials);

foreach ($parts as $part) {
$part = mb_trim($part);
$part = trim($part, " \t");

$pattern = '/^(?<key>[a-zA-Z0-9_-]+)=(?<value>"[^"]*"|[^"]*)$/i';

if (! preg_match($pattern, $part, $match)) {
return null;
}

$key = mb_strtolower($match['key']);
$value = mb_trim($match['value'], '"');
$key = strtolower($match['key']);
$value = trim($match['value'], '"');
$params[$key] = $value;
}

Expand Down Expand Up @@ -323,16 +323,16 @@ private static function parseAws(string $credentials): ?AwsCredential
$parts = explode(',', $credentials);

foreach ($parts as $part) {
$part = mb_trim($part);
$part = trim($part, " \t");

$pattern = '/^(?<key>[a-zA-Z0-9_-]+)=(?<value>[^, ]+)$/';

if (! preg_match($pattern, $part, $match)) {
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;
}

Expand Down
8 changes: 4 additions & 4 deletions src/Header/ContentEncoding.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
4 changes: 2 additions & 2 deletions src/Header/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/Header/TransferEncoding.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
31 changes: 31 additions & 0 deletions tests/HeaderRuntimeCompatibilityTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace FastForward\Http\Message\Tests;

use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\TestCase;

#[CoversNothing]
final class HeaderRuntimeCompatibilityTest extends TestCase
{
public function testHeadersWithoutDevelopmentPolyfillsOrMbstringFunctions(): void
{
$process = proc_open([
PHP_BINARY,
'-d',
'disable_functions=mb_trim,mb_strtolower',
__DIR__ . '/fixtures/header-runtime.php',
], [1 => ['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);
}
}
65 changes: 65 additions & 0 deletions tests/fixtures/header-runtime.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

use FastForward\Http\Message\Header\Accept;
use FastForward\Http\Message\Header\Authorization;
use FastForward\Http\Message\Header\Authorization\AwsCredential;
use FastForward\Http\Message\Header\Authorization\DigestCredential;
use FastForward\Http\Message\Header\ContentEncoding;
use FastForward\Http\Message\Header\ContentType;
use FastForward\Http\Message\Header\TransferEncoding;

// Deliberately do not load dev dependencies: their polyfills can hide runtime bugs.
// An optional Composer --no-dev autoloader can also be checked with this fixture.
// Example on PHP 8.3: php tests/fixtures/header-runtime.php ./vendor/autoload.php
// For a dependency-free source check: php -n tests/fixtures/header-runtime.php
if (isset($argv[1])) {
require $argv[1];
} else {
spl_autoload_register(static function (string $class): void {
$prefix = 'FastForward\\Http\\Message\\';
if (str_starts_with($class, $prefix)) {
require dirname(__DIR__, 2) . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
}
});
}

$checks = [
'Accept' => 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);
Loading