Repository: guzzle/psr7 (php)

Issue:
StreamWrapper::stream_stat() should not always return an array
**PHP version**: >= 8.1

**Description**

`StreamWrapper::stream_stat()` is returning an array no matter what, this can cause problems when the inner stream has a size of `null`, because it's then transformed to `0`. The information "I don't know the size of this stream" is transformed to "this stream is empty".

Also, I'm wondering, why `url_stat()` (implemented in #156) does return a `mode` and  a `size` of `0` unconditionally.

**How to reproduce**

```php
$r = fopen('https://google.com', 'r');
assert(fstat($r) === false);

$stream = Utils::streamFor($r);
assert($stream->getSize() === null);

$r2 = StreamWrapper::getResource($stream);
assert(fstat($r2) === false); // This fails

$stream2 = Utils::streamFor($r2);
assert($stream2->getSize() === null); // This fails too (0 is returned)
```

**Possible Solution**

#594

**Additional context**

I'm trying to send a request with Guzzle, that request has a `null`-sized stream body wrapped in `StreamWrapper` as explained above (for some reasons that I cannot bypass), and Guzzle itself seems to rely on the fact that a `0`-sized body should not be sent in a request : https://github.com/guzzle/guzzle/blob/2779e868a00289e1b1fd854af030c43a06f4bcb4/src/Handler/CurlFactory.php#L251.

As a result, the request is sent without it's body.

Pull request:
[2.6] Make `StreamWrapper::stream_stat()` returns `false` if inner stream's size is `null`
Fixes #593

Patch:
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index b267c7f..77b0684 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -5,6 +5,7 @@ $config = (new PhpCsFixer\Config())
     ->setRules([
         '@PHP71Migration:risky' => true,
         '@PHPUnit75Migration:risky' => true,
+        '@PSR12:risky' => true,
         '@Symfony' => true,
         'global_namespace_import' => false,
         'no_superfluous_phpdoc_tags' => [
diff --git a/README.md b/README.md
index 850fa9d..25092d7 100644
--- a/README.md
+++ b/README.md
@@ -498,7 +498,7 @@ a message.
 
 ## `GuzzleHttp\Psr7\Utils::readLine`
 
-`public static function readLine(StreamInterface $stream, int $maxLength = null): string`
+`public static function readLine(StreamInterface $stream, ?int $maxLength = null): string`
 
 Read a line from the stream up to the maximum allowed buffer length.
 
@@ -674,7 +674,7 @@ termed a relative-path reference.
 
 ### `GuzzleHttp\Psr7\Uri::isSameDocumentReference`
 
-`public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool`
+`public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool`
 
 Whether the URI is a same-document reference. A same-document reference refers to a URI that is, aside from its
 fragment component, identical to the base URI. When no base URI is given, only an empty URI reference
diff --git a/composer.json b/composer.json
index 70293fc..28d15f5 100644
--- a/composer.json
+++ b/composer.json
@@ -61,8 +61,8 @@
     },
     "require-dev": {
         "bamarni/composer-bin-plugin": "^1.8.2",
-        "http-interop/http-factory-tests": "^0.9",
-        "phpunit/phpunit": "^8.5.36 || ^9.6.15"
+        "http-interop/http-factory-tests": "0.9.0",
+        "phpunit/phpunit": "^8.5.39 || ^9.6.20"
     },
     "suggest": {
         "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index 37ed2eb..d2dcc75 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -225,11 +225,6 @@ parameters:
 			count: 1
 			path: src/Utils.php
 
-		-
-			message: "#^Offset 'uri' on array\\{timed_out\\: bool, blocked\\: bool, eof\\: bool, unread_bytes\\: int, stream_type\\: string, wrapper_type\\: string, wrapper_data\\: mixed, mode\\: string, \\.\\.\\.\\} on left side of \\?\\? always exists and is not nullable\\.$#"
-			count: 1
-			path: src/Utils.php
-
 		-
 			message: "#^Parameter \\#1 \\$source of class GuzzleHttp\\\\Psr7\\\\PumpStream constructor expects callable\\(int\\)\\: \\(string\\|false\\|null\\), Closure\\(\\)\\: mixed given\\.$#"
 			count: 1
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
index ce60b79..00a0c1b 100644
--- a/phpstan.neon.dist
+++ b/phpstan.neon.dist
@@ -3,7 +3,9 @@ includes:
     - vendor-bin/phpstan/vendor/phpstan/phpstan-deprecation-rules/rules.neon
 
 parameters:
-    checkMissingIterableValueType: false
+    ignoreErrors:
+        -
+            identifier: missingType.iterableValue
     level: max
     paths:
         - src
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index aa7d70c..b0fc135 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<files psalm-version="5.15.0@5c774aca4746caf3d239d9c8cadb9f882ca29352">
+<files psalm-version="5.25.0@01a8eb06b9e9cc6cfb6a320bf9fb14331919d505">
   <file src="src/FnStream.php">
     <InvalidFunctionCall>
       <code><![CDATA[($this->_fn___toString)()]]></code>
@@ -21,69 +21,74 @@
   </file>
   <file src="src/Header.php">
     <PossiblyUndefinedArrayOffset>
-      <code>$m[0]</code>
+      <code><![CDATA[$m[0]]]></code>
     </PossiblyUndefinedArrayOffset>
   </file>
   <file src="src/HttpFactory.php">
     <ParamNameMismatch>
-      <code>$file</code>
+      <code><![CDATA[$file]]></code>
     </ParamNameMismatch>
   </file>
   <file src="src/Message.php">
+    <PossiblyNullArgument>
+      <code><![CDATA[$matches[1] === '/' ? self::parseRequestUri($parts[1], $data['headers']) : $parts[1]]]></code>
+      <code><![CDATA[$parts[1]]]></code>
+      <code><![CDATA[$parts[1]]]></code>
+    </PossiblyNullArgument>
     <PossiblyUndefinedArrayOffset>
-      <code>$parts[1]</code>
-      <code>$parts[1]</code>
-      <code>$parts[1]</code>
-      <code>$parts[1]</code>
+      <code><![CDATA[$parts[1]]]></code>
+      <code><![CDATA[$parts[1]]]></code>
+      <code><![CDATA[$parts[1]]]></code>
+      <code><![CDATA[$parts[1]]]></code>
     </PossiblyUndefinedArrayOffset>
   </file>
   <file src="src/MessageTrait.php">
     <LessSpecificImplementedReturnType>
-      <code>MessageInterface</code>
-      <code>MessageInterface</code>
-      <code>MessageInterface</code>
-      <code>MessageInterface</code>
-      <code>MessageInterface</code>
-      <code>array</code>
-      <code>array</code>
+      <code><![CDATA[MessageInterface]]></code>
+      <code><![CDATA[MessageInterface]]></code>
+      <code><![CDATA[MessageInterface]]></code>
+      <code><![CDATA[MessageInterface]]></code>
+      <code><![CDATA[MessageInterface]]></code>
+      <code><![CDATA[array]]></code>
+      <code><![CDATA[array]]></code>
     </LessSpecificImplementedReturnType>
     <ParamNameMismatch>
-      <code>$header</code>
-      <code>$header</code>
-      <code>$header</code>
-      <code>$header</code>
-      <code>$header</code>
-      <code>$header</code>
+      <code><![CDATA[$header]]></code>
+      <code><![CDATA[$header]]></code>
+      <code><![CDATA[$header]]></code>
+      <code><![CDATA[$header]]></code>
+      <code><![CDATA[$header]]></code>
+      <code><![CDATA[$header]]></code>
     </ParamNameMismatch>
   </file>
   <file src="src/Request.php">
     <NoValue>
-      <code>$header</code>
+      <code><![CDATA[$header]]></code>
     </NoValue>
   </file>
   <file src="src/Response.php">
     <RedundantCast>
-      <code>(int) $code</code>
-      <code>(string) $reasonPhrase</code>
+      <code><![CDATA[(int) $code]]></code>
+      <code><![CDATA[(string) $reasonPhrase]]></code>
     </RedundantCast>
   </file>
   <file src="src/ServerRequest.php">
     <InvalidArgument>
-      <code>$_SERVER['SERVER_PORT']</code>
+      <code><![CDATA[$_SERVER['SERVER_PORT']]]></code>
     </InvalidArgument>
     <InvalidArrayOffset>
-      <code>$normalizedFiles[$key]</code>
+      <code><![CDATA[$normalizedFiles[$key]]]></code>
     </InvalidArrayOffset>
     <InvalidReturnStatement>
-      <code>$normalizedFiles</code>
+      <code><![CDATA[$normalizedFiles]]></code>
     </InvalidReturnStatement>
     <InvalidReturnType>
-      <code>UploadedFileInterface[]</code>
+      <code><![CDATA[UploadedFileInterface[]]]></code>
     </InvalidReturnType>
     <ParamNameMismatch>
-      <code>$attribute</code>
-      <code>$attribute</code>
-      <code>$attribute</code>
+      <code><![CDATA[$attribute]]></code>
+      <code><![CDATA[$attribute]]></code>
+      <code><![CDATA[$attribute]]></code>
     </ParamNameMismatch>
   </file>
   <file src="src/Stream.php">
@@ -91,7 +96,7 @@
       <code><![CDATA[$this->stream]]></code>
     </InvalidPropertyAssignmentValue>
     <RedundantCast>
-      <code>(int) $whence</code>
+      <code><![CDATA[(int) $whence]]></code>
     </RedundantCast>
     <RedundantPropertyInitializationCheck>
       <code><![CDATA[isset($this->stream)]]></code>
@@ -108,25 +113,25 @@
   </file>
   <file src="src/Uri.php">
     <PossiblyInvalidArgument>
-      <code>$result</code>
+      <code><![CDATA[$result]]></code>
     </PossiblyInvalidArgument>
   </file>
   <file src="src/UriResolver.php">
     <TypeDoesNotContainType>
-      <code>'' === $relativePath</code>
+      <code><![CDATA['' === $relativePath]]></code>
     </TypeDoesNotContainType>
   </file>
   <file src="src/Utils.php">
     <FalsableReturnStatement>
-      <code>$contents</code>
+      <code><![CDATA[$contents]]></code>
     </FalsableReturnStatement>
     <MissingDocblockType>
-      <code>throw $ex;</code>
-      <code>throw $ex;</code>
+      <code><![CDATA[throw $ex;]]></code>
+      <code><![CDATA[throw $ex;]]></code>
     </MissingDocblockType>
     <PossiblyUndefinedVariable>
-      <code>$contents</code>
-      <code>$handle</code>
+      <code><![CDATA[$contents]]></code>
+      <code><![CDATA[$handle]]></code>
     </PossiblyUndefinedVariable>
   </file>
 </files>
diff --git a/psalm.xml b/psalm.xml
index 04d8486..86d8671 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -6,6 +6,8 @@
     xmlns="https://getpsalm.org/schema/config"
     xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
     errorBaseline="psalm-baseline.xml"
+    findUnusedBaselineEntry="true"
+    findUnusedCode="false"
 >
     <projectFiles>
         <directory name="src" />
diff --git a/src/CachingStream.php b/src/CachingStream.php
index f34722c..7e4554d 100644
--- a/src/CachingStream.php
+++ b/src/CachingStream.php
@@ -33,7 +33,7 @@ final class CachingStream implements StreamInterface
      */
     public function __construct(
         StreamInterface $stream,
-        StreamInterface $target = null
+        ?StreamInterface $target = null
     ) {
         $this->remoteStream = $stream;
         $this->stream = $target ?: new Stream(Utils::tryFopen('php://temp', 'r+'));
diff --git a/src/HttpFactory.php b/src/HttpFactory.php
index 73d17e3..3ef1510 100644
--- a/src/HttpFactory.php
+++ b/src/HttpFactory.php
@@ -27,10 +27,10 @@ final class HttpFactory implements RequestFactoryInterface, ResponseFactoryInter
 {
     public function createUploadedFile(
         StreamInterface $stream,
-        int $size = null,
+        ?int $size = null,
         int $error = \UPLOAD_ERR_OK,
-        string $clientFilename = null,
-        string $clientMediaType = null
+        ?string $clientFilename = null,
+        ?string $clientMediaType = null
     ): UploadedFileInterface {
         if ($size === null) {
             $size = $stream->getSize();
diff --git a/src/MultipartStream.php b/src/MultipartStream.php
index d23fba8..43d718f 100644
--- a/src/MultipartStream.php
+++ b/src/MultipartStream.php
@@ -32,7 +32,7 @@ final class MultipartStream implements StreamInterface
      *
      * @throws \InvalidArgumentException
      */
-    public function __construct(array $elements = [], string $boundary = null)
+    public function __construct(array $elements = [], ?string $boundary = null)
     {
         $this->boundary = $boundary ?: bin2hex(random_bytes(20));
         $this->stream = $this->createStream($elements);
diff --git a/src/Response.php b/src/Response.php
index 00f16e2..34e612f 100644
--- a/src/Response.php
+++ b/src/Response.php
@@ -96,7 +96,7 @@ class Response implements ResponseInterface
         array $headers = [],
         $body = null,
         string $version = '1.1',
-        string $reason = null
+        ?string $reason = null
     ) {
         $this->assertStatusCodeRange($status);
 
diff --git a/src/StreamWrapper.php b/src/StreamWrapper.php
index ae85388..77b04d7 100644
--- a/src/StreamWrapper.php
+++ b/src/StreamWrapper.php
@@ -69,7 +69,7 @@ final class StreamWrapper
         }
     }
 
-    public function stream_open(string $path, string $mode, int $options, string &$opened_path = null): bool
+    public function stream_open(string $path, string $mode, int $options, ?string &$opened_path = null): bool
     {
         $options = stream_context_get_options($this->context);
 
@@ -136,10 +136,14 @@ final class StreamWrapper
      *   ctime: int,
      *   blksize: int,
      *   blocks: int
-     * }
+     * }|false
      */
-    public function stream_stat(): array
+    public function stream_stat()
     {
+        if ($this->stream->getSize() === null) {
+            return false;
+        }
+
         static $modeMap = [
             'r' => 33060,
             'rb' => 33060,
diff --git a/src/UploadedFile.php b/src/UploadedFile.php
index b267199..9c9ea49 100644
--- a/src/UploadedFile.php
+++ b/src/UploadedFile.php
@@ -64,8 +64,8 @@ class UploadedFile implements UploadedFileInterface
         $streamOrFile,
         ?int $size,
         int $errorStatus,
-        string $clientFilename = null,
-        string $clientMediaType = null
+        ?string $clientFilename = null,
+        ?string $clientMediaType = null
     ) {
         $this->setError($errorStatus);
         $this->size = $size;
diff --git a/src/Uri.php b/src/Uri.php
index f1feee8..481dfca 100644
--- a/src/Uri.php
+++ b/src/Uri.php
@@ -279,7 +279,7 @@ class Uri implements UriInterface, \JsonSerializable
      *
      * @see https://datatracker.ietf.org/doc/html/rfc3986#section-4.4
      */
-    public static function isSameDocumentReference(UriInterface $uri, UriInterface $base = null): bool
+    public static function isSameDocumentReference(UriInterface $uri, ?UriInterface $base = null): bool
     {
         if ($base !== null) {
             $uri = UriResolver::resolve($base, $uri);
diff --git a/src/Utils.php b/src/Utils.php
index bf5ea9d..b81dab3 100644
--- a/src/Utils.php
+++ b/src/Utils.php
@@ -231,7 +231,7 @@ final class Utils
      * @param StreamInterface $stream    Stream to read from
      * @param int|null        $maxLength Maximum buffer length
      */
-    public static function readLine(StreamInterface $stream, int $maxLength = null): string
+    public static function readLine(StreamInterface $stream, ?int $maxLength = null): string
     {
         $buffer = '';
         $size = 0;
diff --git a/vendor-bin/php-cs-fixer/composer.json b/vendor-bin/php-cs-fixer/composer.json
index 3e8c9df..72998c7 100644
--- a/vendor-bin/php-cs-fixer/composer.json
+++ b/vendor-bin/php-cs-fixer/composer.json
@@ -1,7 +1,7 @@
 {
     "require": {
         "php": "^7.4 || ^8.0",
-        "friendsofphp/php-cs-fixer": "3.40.2"
+        "friendsofphp/php-cs-fixer": "3.59.3"
     },
     "config": {
         "preferred-install": "dist"
diff --git a/vendor-bin/phpstan/composer.json b/vendor-bin/phpstan/composer.json
index 6207691..77598be 100644
--- a/vendor-bin/phpstan/composer.json
+++ b/vendor-bin/phpstan/composer.json
@@ -1,8 +1,8 @@
 {
     "require": {
         "php": "^7.4 || ^8.0",
-        "phpstan/phpstan": "1.10.47",
-        "phpstan/phpstan-deprecation-rules": "1.1.4"
+        "phpstan/phpstan": "1.11.7",
+        "phpstan/phpstan-deprecation-rules": "1.2.0"
     },
     "config": {
         "preferred-install": "dist"
diff --git a/vendor-bin/psalm/composer.json b/vendor-bin/psalm/composer.json
index 2f215e8..020abe5 100644
--- a/vendor-bin/psalm/composer.json
+++ b/vendor-bin/psalm/composer.json
@@ -1,7 +1,7 @@
 {
     "require": {
         "php": "^7.4 || ^8.0",
-        "psalm/phar": "5.16.0"
+        "psalm/phar": "5.25.0"
     },
     "config": {
         "preferred-install": "dist"


Test patch:
diff --git a/tests/Integration/server.php b/tests/Integration/server.php
index 0b887fc..6d7edbf 100644
--- a/tests/Integration/server.php
+++ b/tests/Integration/server.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
 
 require dirname(__DIR__, 2).'/vendor/autoload.php';
 
-$request = \GuzzleHttp\Psr7\ServerRequest::fromGlobals();
+$request = GuzzleHttp\Psr7\ServerRequest::fromGlobals();
 
 $output = [
     'method' => $request->getMethod(),
diff --git a/tests/StreamWrapperTest.php b/tests/StreamWrapperTest.php
index 153095d..e30d177 100644
--- a/tests/StreamWrapperTest.php
+++ b/tests/StreamWrapperTest.php
@@ -6,6 +6,7 @@ namespace GuzzleHttp\Tests\Psr7;
 
 use GuzzleHttp\Psr7;
 use GuzzleHttp\Psr7\StreamWrapper;
+use GuzzleHttp\Psr7\Utils;
 use PHPUnit\Framework\TestCase;
 use Psr\Http\Message\StreamInterface;
 
@@ -187,4 +188,15 @@ class StreamWrapperTest extends TestCase
         $stream->rewind();
         self::assertXmlStringEqualsXmlString('<?xml version="1.0"?><foo />', (string) $stream);
     }
+
+    public function testWrappedNullSizedStreamStaysNullSized(): void
+    {
+        $nullSizedStream = new Psr7\PumpStream(function () { return ''; });
+        $this->assertNull($nullSizedStream->getSize());
+
+        $resource = StreamWrapper::getResource($nullSizedStream);
+        $stream = Utils::streamFor($resource);
+
+        $this->assertNull($stream->getSize());
+    }
 }
diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php
index c8a4bcd..704010e 100644
--- a/tests/UtilsTest.php
+++ b/tests/UtilsTest.php
@@ -77,7 +77,7 @@ class UtilsTest extends TestCase
     public function testCopyToStreamReadsInChunksInsteadOfAllInMemory(): void
     {
         $sizes = [];
-        $s1 = new Psr7\FnStream([
+        $s1 = new FnStream([
             'eof' => function () {
                 return false;
             },