From 66bfc1b883453a6e6fa58227addc351db83d32b4 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 20 Jun 2026 22:23:54 +0200 Subject: [PATCH 1/4] Add support for encoding and language in header parameters See https://www.rfc-editor.org/info/rfc8187/ --- src/main/php/web/Headers.class.php | 18 +++ src/main/php/web/Parameterized.class.php | 58 +++++++- .../php/web/unittest/HeadersTest.class.php | 29 ++++ .../php/web/unittest/ResponseTest.class.php | 134 ++++++++++++------ 4 files changed, 191 insertions(+), 48 deletions(-) diff --git a/src/main/php/web/Headers.class.php b/src/main/php/web/Headers.class.php index 3c492ec1..a7162e40 100755 --- a/src/main/php/web/Headers.class.php +++ b/src/main/php/web/Headers.class.php @@ -11,6 +11,7 @@ * @test web.unittest.HeadersTest */ abstract class Headers { + const ATTR_CHAR= 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_'; /** * Formats a date for use in headers @@ -162,6 +163,23 @@ protected function next($input, &$offset) { // consistency with PHP, see https://github.com/php/php-src/issues/8206 $parameters[$name]= strtr(substr($input, $offset + 1, $p - $offset - 2), ['\"' => '"']); $offset= $p + 1; + } else if ('*' === $name[strlen($name) - 1]) { + + // RFC 8187: Character Encoding and Language for HTTP Header Field Parameters + $s= strcspn($input, "'", $offset); + $charset= substr($input, $offset, $s); + $offset+= $s + 1; + + $s= strcspn($input, "'", $offset); + $lang= substr($input, $offset, $s); + $offset+= $s + 1; + + $s= strcspn($input, ',;', $offset); + $parameters[$name]= [ + 'lang' => $lang ?: null, + 'value' => iconv($charset, \xp::ENCODING, urldecode(substr($input, $offset, $s))) + ]; + $offset+= $s + 1; } else { $s= strcspn($input, ',;', $offset); $parameters[$name]= substr($input, $offset, $s); diff --git a/src/main/php/web/Parameterized.class.php b/src/main/php/web/Parameterized.class.php index 149e8d68..e216243b 100755 --- a/src/main/php/web/Parameterized.class.php +++ b/src/main/php/web/Parameterized.class.php @@ -7,28 +7,74 @@ class Parameterized { * Creates a new instance * * @param string $value - * @param [:string] $params + * @param [:var] $params */ - public function __construct($value, array $params) { + public function __construct($value, array $params= []) { $this->value= $value; $this->params= $params; } + /** + * Passes parameters and optionally, their ASCII equivalents. + * + * @param string $name + * @param string|[:string] $value + * @param ?string $equivalent + * @return self + */ + public function with($name, $value, $equivalent= null) { + $name= rtrim($name, '*'); + null === $equivalent || $this->params[$name]= $equivalent; + + if (is_array($value)) { + $this->params[$name.'*']= 1 === sizeof($value) + ? ['lang' => current($value), 'value' => key($value)] + : $value + ; + } else if (isset($equivalent) || preg_match('/[\x7f-\xff]/', $value)) { + $this->params[$name.'*']= ['lang' => null, 'value' => $value]; + } else { + $this->params[$name]= $value; + } + + return $this; + } + /** @return string */ public function value() { return $this->value; } - /** @return [:string] */ + /** @return [:var] */ public function params() { return $this->params; } /** - * Gets a parameter by its name, returning a default value if it's - * not present. + * Gets a parameter by its name, returning a default value if it's not + * present. Prefers the RFC 8187 encoded parameter ending with `*`. * * @param string $name * @param var $default * @return var */ public function param($name, $default= null) { - return $this->params[$name] ?? $default; + if ($param= $this->params[$name.'*'] ?? null) { + return $param['value']; + } else { + return $this->params[$name] ?? $default; + } + } + + /** @return string */ + public function __toString() { + $s= $this->value; + foreach ($this->params as $name => $value) { + $s.= '; '.$name; + if (is_array($value)) { + $s.= "=UTF-8'{$value['lang']}'".rawurlencode($value['value']); + } else if (strspn($value, Headers::ATTR_CHAR) < strlen($value)) { + $s.= '="'.strtr($value, ['\\' => '\\\\', '"' => '\\"']).'"'; + } else { + $s.= '='.$value; + } + } + return $s; } } \ No newline at end of file diff --git a/src/test/php/web/unittest/HeadersTest.class.php b/src/test/php/web/unittest/HeadersTest.class.php index ddfa04e9..a26bbe57 100755 --- a/src/test/php/web/unittest/HeadersTest.class.php +++ b/src/test/php/web/unittest/HeadersTest.class.php @@ -34,6 +34,35 @@ public function content_disposition($header) { ); } + #[Test] + public function rfc8187_encoding() { + $parameterized= Headers::parameterized()->parse("attachment; filename*=UTF-8''%C3%BCber%20name.jpg"); + + Assert::equals('attachment', $parameterized->value()); + Assert::equals(['filename*' => ['lang' => null, 'value' => 'über name.jpg']], $parameterized->params()); + Assert::equals('über name.jpg', $parameterized->param('filename')); + } + + #[Test] + public function rfc8187_encoding_and_language() { + $parameterized= Headers::parameterized()->parse("attachment; filename*=UTF-8'en'file%20name.jpg"); + + Assert::equals('attachment', $parameterized->value()); + Assert::equals(['filename*' => ['lang' => 'en', 'value' => 'file name.jpg']], $parameterized->params()); + Assert::equals('file name.jpg', $parameterized->param('filename')); + } + + #[Test] + public function rfc8187_encoded_takes_precedence() { + $parameterized= Headers::parameterized()->parse("attachment; filename=\"ascii.jpg\"; filename*=UTF-8''unicode.jpg"); + + Assert::equals( + ['filename' => 'ascii.jpg', 'filename*' => ['lang' => null, 'value' => 'unicode.jpg']], + $parameterized->params() + ); + Assert::equals('unicode.jpg', $parameterized->param('filename')); + } + #[Test, Values(['5;url=http://www.w3.org/pub/WWW/People.html', '5; url=http://www.w3.org/pub/WWW/People.html',])] public function refresh($header) { Assert::equals( diff --git a/src/test/php/web/unittest/ResponseTest.class.php b/src/test/php/web/unittest/ResponseTest.class.php index 13b9b4d3..17137cc0 100755 --- a/src/test/php/web/unittest/ResponseTest.class.php +++ b/src/test/php/web/unittest/ResponseTest.class.php @@ -6,19 +6,42 @@ use test\{Assert, Expect, Test, Values}; use util\URI; use web\io\{Buffered, TestOutput}; -use web\{Cookie, Response}; +use web\{Cookie, Response, Parameterized}; class ResponseTest { - /** - * Assertion helper - * - * @param string $expected - * @param web.Response $response - * @throws unittest.AssertionFailedError - */ - private function assertResponse($expected, $response) { - Assert::equals($expected, $response->output()->bytes()); + /** @return iterable */ + private function answers() { + + // Supplied as-is + yield [200, 'OK', 'HTTP/1.1 200 OK']; + yield [404, 'Not Found', 'HTTP/1.1 404 Not Found']; + + // Status message derived from code + yield [200, null, 'HTTP/1.1 200 OK']; + yield [404, null, 'HTTP/1.1 404 Not Found']; + + // Status message overridden + yield [200, 'Okay', 'HTTP/1.1 200 Okay']; + yield [404, 'Nope', 'HTTP/1.1 404 Nope']; + } + + /** @return iterable */ + private function filenames() { + + // Simple file names + yield ['test.txt', 'filename=test.txt']; + yield ['a b.txt', 'filename="a b.txt"']; + yield ['"hello".txt', 'filename="\"hello\".txt"']; + yield ['über.txt', "filename*=UTF-8''%C3%BCber.txt"]; + + // In the form of [name => lang] + yield [['test.txt' => null], "filename*=UTF-8''test.txt"]; + yield [['über.txt' => null], "filename*=UTF-8''%C3%BCber.txt"]; + yield [['über.txt' => 'de'], "filename*=UTF-8'de'%C3%BCber.txt"]; + + // As received from Parameterized::params() + yield [['lang' => 'de', 'value' => 'über.txt'], "filename*=UTF-8'de'%C3%BCber.txt"]; } #[Test] @@ -71,10 +94,7 @@ public function headers() { $res= new Response(new TestOutput()); $res->header('Content-Type', 'text/plain'); $res->header('Content-Length', '0'); - Assert::equals( - ['Content-Type' => 'text/plain', 'Content-Length' => '0'], - $res->headers() - ); + Assert::equals(['Content-Type' => 'text/plain', 'Content-Length' => '0'], $res->headers()); } #[Test] @@ -142,7 +162,7 @@ public function cookies() { Assert::equals($cookies, $res->cookies()); } - #[Test, Values([[200, 'OK', 'HTTP/1.1 200 OK'], [404, 'Not Found', 'HTTP/1.1 404 Not Found'], [200, null, 'HTTP/1.1 200 OK'], [404, null, 'HTTP/1.1 404 Not Found'], [200, 'Okay', 'HTTP/1.1 200 Okay'], [404, 'Nope', 'HTTP/1.1 404 Nope']])] + #[Test, Values(from: 'answers')] public function answer($status, $message, $line) { $out= new TestOutput(); @@ -150,7 +170,7 @@ public function answer($status, $message, $line) { $res->answer($status, $message); $res->flush(); - $this->assertResponse($line."\r\n\r\n", $res); + Assert::that($res->output()->bytes())->isEqualTo($line."\r\n\r\n"); } #[Test] @@ -167,7 +187,7 @@ public function hint() { $res->answer(200, 'OK'); $res->flush(); - $this->assertResponse("HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\n\r\n", $res); + Assert::that($res->output()->bytes())->isEqualTo("HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\n\r\n"); } #[Test] @@ -178,12 +198,11 @@ public function hint_with_headers() { 'Connection' => 'Upgrade' ]); - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 101 Switching Protocols\r\n". "Upgrade: websocket\r\n". "Connection: Upgrade\r\n". - "\r\n", - $res + "\r\n" ); } @@ -195,7 +214,7 @@ public function hint_uses_and_retains_previously_set_headers() { $res->answer(200, 'OK'); $res->flush(); - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 103 Early Hints\r\n". "Link: ; rel=preload; as=style\r\n". "Link: ; rel=preload; as=script\r\n". @@ -203,8 +222,7 @@ public function hint_uses_and_retains_previously_set_headers() { "HTTP/1.1 200 OK\r\n". "Link: ; rel=preload; as=style\r\n". "Link: ; rel=preload; as=script\r\n". - "\r\n", - $res + "\r\n" ); } @@ -215,7 +233,12 @@ public function send_headers() { $res->header('Content-Length', '0'); $res->flush(); - $this->assertResponse("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 0\r\n\r\n", $res); + Assert::that($res->output()->bytes())->isEqualTo( + "HTTP/1.1 200 OK\r\n". + "Content-Type: text/plain\r\n". + "Content-Length: 0\r\n". + "\r\n" + ); } #[Test] @@ -223,10 +246,39 @@ public function send_html() { $res= new Response(new TestOutput()); $res->send('

Test

', 'text/html'); - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 13\r\n\r\n". - "

Test

", - $res + "

Test

" + ); + } + + #[Test, Values(from: 'filenames')] + public function send_file($name, $params) { + $res= new Response(new TestOutput()); + $res->header('Content-Disposition', (new Parameterized('attachment'))->with('filename', $name)); + $res->flush(); + + Assert::that($res->output()->bytes())->isEqualTo( + "HTTP/1.1 200 OK\r\n". + "Content-Disposition: attachment; {$params}\r\n". + "\r\n" + ); + } + + #[Test, Values(['uber.txt', 'über.txt'])] + public function send_file_rfc8187_with_ascii_equivalent($name) { + $res= new Response(new TestOutput()); + $res->header('Content-Disposition', (new Parameterized('attachment'))->with( + 'filename', + $name, + $equivalent= 'uber.txt' + )); + $res->flush(); + + Assert::that($res->output()->bytes())->isEqualTo( + "HTTP/1.1 200 OK\r\n". + "Content-Disposition: attachment; filename=uber.txt; filename*=UTF-8''".rawurlencode($name)."\r\n". + "\r\n" ); } @@ -235,10 +287,9 @@ public function transmit_stream_with_length() { $res= new Response(new TestOutput()); foreach ($res->transmit(new MemoryInputStream('

Test

'), 'text/html', 13) as $_) { } - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 13\r\n\r\n". - "

Test

", - $res + "

Test

" ); } @@ -247,10 +298,9 @@ public function transmit_stream_chunked() { $res= new Response(new TestOutput()); foreach ($res->transmit(new MemoryInputStream('

Test

'), 'text/html') as $_) { } - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nTransfer-Encoding: chunked\r\n\r\n". - "d\r\n

Test

\r\n0\r\n\r\n", - $res + "d\r\n

Test

\r\n0\r\n\r\n" ); } @@ -259,10 +309,9 @@ public function transmit_stream_buffered() { $res= new Response(new TestOutput(Buffered::class)); foreach ($res->transmit(new MemoryInputStream('

Test

'), 'text/html') as $_) { } - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 13\r\n\r\n". - "

Test

", - $res + "

Test

" ); } @@ -275,10 +324,9 @@ public function out() { /* Not implemented */ } }; foreach ($res->transmit($channel, 'text/html', 13) as $_) { } - $this->assertResponse( + Assert::that($res->output()->bytes())->isEqualTo( "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 13\r\n\r\n". - "

Test

", - $res + "

Test

" ); } @@ -295,9 +343,11 @@ public function cookies_and_headers_are_merged() { $res->cookie(new Cookie('toggle', 'future')); $res->flush(); - $this->assertResponse( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nSet-Cookie: toggle=future; SameSite=Lax; HttpOnly\r\n\r\n", - $res + Assert::that($res->output()->bytes())->isEqualTo( + "HTTP/1.1 200 OK\r\n". + "Content-Type: text/html\r\n". + "Set-Cookie: toggle=future; SameSite=Lax; HttpOnly\r\n". + "\r\n" ); } From 58229ac643d28dc4ca65eb8a31b71d5a538ebb62 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 20 Jun 2026 22:42:11 +0200 Subject: [PATCH 2/4] Add equivalent() accessor --- src/main/php/web/Parameterized.class.php | 12 ++++++++++++ src/test/php/web/unittest/HeadersTest.class.php | 3 +++ 2 files changed, 15 insertions(+) diff --git a/src/main/php/web/Parameterized.class.php b/src/main/php/web/Parameterized.class.php index e216243b..9f261f36 100755 --- a/src/main/php/web/Parameterized.class.php +++ b/src/main/php/web/Parameterized.class.php @@ -62,6 +62,18 @@ public function param($name, $default= null) { } } + /** + * Gets a parameter equivalent by its name, returning a default value + * if it's not present. + * + * @param string $name + * @param var $default + * @return var + */ + public function equivalent($name, $default= null) { + return $this->params[$name] ?? $default; + } + /** @return string */ public function __toString() { $s= $this->value; diff --git a/src/test/php/web/unittest/HeadersTest.class.php b/src/test/php/web/unittest/HeadersTest.class.php index a26bbe57..e838338b 100755 --- a/src/test/php/web/unittest/HeadersTest.class.php +++ b/src/test/php/web/unittest/HeadersTest.class.php @@ -41,6 +41,7 @@ public function rfc8187_encoding() { Assert::equals('attachment', $parameterized->value()); Assert::equals(['filename*' => ['lang' => null, 'value' => 'über name.jpg']], $parameterized->params()); Assert::equals('über name.jpg', $parameterized->param('filename')); + Assert::null($parameterized->equivalent('filename')); } #[Test] @@ -50,6 +51,7 @@ public function rfc8187_encoding_and_language() { Assert::equals('attachment', $parameterized->value()); Assert::equals(['filename*' => ['lang' => 'en', 'value' => 'file name.jpg']], $parameterized->params()); Assert::equals('file name.jpg', $parameterized->param('filename')); + Assert::null($parameterized->equivalent('filename')); } #[Test] @@ -61,6 +63,7 @@ public function rfc8187_encoded_takes_precedence() { $parameterized->params() ); Assert::equals('unicode.jpg', $parameterized->param('filename')); + Assert::equals('ascii.jpg', $parameterized->equivalent('filename')); } #[Test, Values(['5;url=http://www.w3.org/pub/WWW/People.html', '5; url=http://www.w3.org/pub/WWW/People.html',])] From 44acbd1c57e0665ac8ecb6b1d4bfa13a134df4fe Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 20 Jun 2026 22:43:10 +0200 Subject: [PATCH 3/4] Ignore PHP 8.6 for the moment until regression with it is fixed --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f1aa162..203a1594 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5', '8.6'] + php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] os: [ubuntu-latest, windows-latest] steps: From daf9ba0f8ce30d37833b8a672c2160a2edc41917 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 21 Jun 2026 20:53:25 +0200 Subject: [PATCH 4/4] MFH I/O Exceptions refactoring --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 203a1594..1f1aa162 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5', '8.6'] os: [ubuntu-latest, windows-latest] steps: