Parcourir la source

Bump SimplePie with PHPStan Level 8 (#7775)

* Bump SimplePie with PHPStan Level 8
* https://github.com/FreshRSS/simplepie/pull/45
SimplePie increased to PHPStan Level 8:
* https://github.com/simplepie/simplepie/pull/857

* Merge upstream
Including my two PRs:
* https://github.com/simplepie/simplepie/pull/932
* https://github.com/simplepie/simplepie/pull/933

* Resolve upstream sync of Expose HTTP status
* https://github.com/FreshRSS/simplepie/pull/47
Finalise merge, following:
* https://github.com/simplepie/simplepie/pull/905#issuecomment-3007605779
* https://github.com/simplepie/simplepie/pull/909
* https://github.com/FreshRSS/FreshRSS/issues/7038
Alexandre Alapetite il y a 8 mois
Parent
commit
b817598f57

+ 1 - 1
lib/composer.json

@@ -14,7 +14,7 @@
         "marienfressinaud/lib_opml": "0.5.1",
         "phpgt/cssxpath": "v1.3.0",
         "phpmailer/phpmailer": "6.10.0",
-        "simplepie/simplepie": "dev-freshrss#d80757267ea1fcbe13d1d1e3a73c2e81f23440de"
+        "simplepie/simplepie": "dev-freshrss#3cdee69fde431e6b461b87413177a791c2018098"
     },
     "config": {
         "sort-packages": true,

+ 0 - 1
lib/simplepie/simplepie/.gitignore

@@ -1,7 +1,6 @@
 *sandbox*
 demo/cache/*
 SimplePie.compiled.php
-bin/
 vendor/
 composer.lock
 phpstan.neon

+ 1 - 6
lib/simplepie/simplepie/phpstan.neon.dist

@@ -1,5 +1,5 @@
 parameters:
-    level: 6
+    level: 8
 
     paths:
         - library/
@@ -55,11 +55,6 @@ parameters:
             # Only occurs on PHP ≤ 7.4
             reportUnmatched: false
 
-        -
-            message: '(^Unable to resolve the template type T in call to method SimplePie\\Registry::get_class\(\)$)'
-            count: 2
-            path: tests/Unit/RegistryTest.php
-
         # PHPStan stubs bug https://github.com/phpstan/phpstan/issues/8629
         -
             message: '(^Access to an undefined property XMLReader::\$\w+\.$)'

+ 7 - 2
lib/simplepie/simplepie/src/Cache.php

@@ -97,8 +97,13 @@ class Cache
      */
     public static function parse_URL(string $url)
     {
-        $params = parse_url($url);
-        $params['extras'] = [];
+        $parsedUrl = parse_url($url);
+
+        if ($parsedUrl === false) {
+            return [];
+        }
+
+        $params = array_merge($parsedUrl, ['extras' => []]);
         if (isset($params['query'])) {
             parse_str($params['query'], $params['extras']);
         }

+ 4 - 1
lib/simplepie/simplepie/src/Cache/CallableNameFilter.php

@@ -13,10 +13,13 @@ namespace SimplePie\Cache;
 final class CallableNameFilter implements NameFilter
 {
     /**
-     * @var callable
+     * @var callable(string): string
      */
     private $callable;
 
+    /**
+     * @param callable(string): string $callable
+     */
     public function __construct(callable $callable)
     {
         $this->callable = $callable;

+ 1 - 1
lib/simplepie/simplepie/src/Cache/File.php

@@ -85,7 +85,7 @@ class File implements Base
     public function load()
     {
         if (file_exists($this->name) && is_readable($this->name)) {
-            return unserialize(file_get_contents($this->name));
+            return unserialize((string) file_get_contents($this->name));
         }
         return false;
     }

+ 2 - 2
lib/simplepie/simplepie/src/Cache/MySQL.php

@@ -271,7 +271,7 @@ class MySQL extends DB
                     $query->bindValue(':feed', $this->id);
                     if ($query->execute()) {
                         while ($row = $query->fetchColumn()) {
-                            $feed['child'][\SimplePie\SimplePie::NAMESPACE_ATOM_10]['entry'][] = unserialize($row);
+                            $feed['child'][\SimplePie\SimplePie::NAMESPACE_ATOM_10]['entry'][] = unserialize((string) $row);
                         }
                     } else {
                         return false;
@@ -297,7 +297,7 @@ class MySQL extends DB
         $query = $this->mysql->prepare('SELECT `mtime` FROM `' . $this->options['extras']['prefix'] . 'cache_data` WHERE `id` = :id');
         $query->bindValue(':id', $this->id);
         if ($query->execute() && ($time = $query->fetchColumn())) {
-            return $time;
+            return (int) $time;
         }
 
         return false;

+ 17 - 9
lib/simplepie/simplepie/src/Enclosure.php

@@ -253,7 +253,7 @@ class Enclosure
         if (function_exists('idn_to_ascii')) {
             $parsed = \SimplePie\Misc::parse_url($link ?? '');
             if ($parsed['authority'] !== '' && !ctype_print($parsed['authority'])) {
-                $authority = \idn_to_ascii($parsed['authority'], \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46);
+                $authority = (string) \idn_to_ascii($parsed['authority'], \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46);
                 $this->link = \SimplePie\Misc::compress_parse_url($parsed['scheme'], $authority, $parsed['path'], $parsed['query'], $parsed['fragment']);
             }
         }
@@ -931,7 +931,7 @@ class Enclosure
             }
         }
 
-        $mime = explode('/', $type, 2);
+        $mime = explode('/', (string) $type, 2);
         $mime = $mime[0];
 
         // Process values for 'auto'
@@ -992,7 +992,10 @@ class Enclosure
         // Flash Media Player file types.
         // Preferred handler for MP3 file types.
         elseif ($handler === 'fmedia' || ($handler === 'mp3' && $mediaplayer !== '')) {
-            $height += 20;
+            if (is_numeric($height)) {
+                $height += 20;
+            }
+
             if ($native) {
                 $embed .= "<embed src=\"$mediaplayer\" pluginspage=\"http://adobe.com/go/getflashplayer\" type=\"application/x-shockwave-flash\" quality=\"high\" width=\"$width\" height=\"$height\" wmode=\"transparent\" flashvars=\"file=" . rawurlencode($this->get_link().'?file_extension=.'.$this->get_extension()) . "&autostart=false&repeat=$loop&showdigits=true&showfsbutton=false\"></embed>";
             } else {
@@ -1003,7 +1006,10 @@ class Enclosure
         // QuickTime 7 file types.  Need to test with QuickTime 6.
         // Only handle MP3's if the Flash Media Player is not present.
         elseif ($handler === 'quicktime' || ($handler === 'mp3' && $mediaplayer === '')) {
-            $height += 16;
+            if (is_numeric($height)) {
+                $height += 16;
+            }
+
             if ($native) {
                 if ($placeholder !== '') {
                     $embed .= "<embed type=\"$type\" style=\"cursor:hand; cursor:pointer;\" href=\"" . $this->get_link() . "\" src=\"$placeholder\" width=\"$width\" height=\"$height\" autoplay=\"false\" target=\"myself\" controller=\"false\" loop=\"$loop\" scale=\"aspect\" bgcolor=\"$bgcolor\" pluginspage=\"http://apple.com/quicktime/download/\"></embed>";
@@ -1017,7 +1023,10 @@ class Enclosure
 
         // Windows Media
         elseif ($handler === 'wmedia') {
-            $height += 45;
+            if (is_numeric($height)) {
+                $height += 45;
+            }
+
             if ($native) {
                 $embed .= "<embed type=\"application/x-mplayer2\" src=\"" . $this->get_link() . "\" autosize=\"1\" width=\"$width\" height=\"$height\" showcontrols=\"1\" showstatusbar=\"0\" showdisplay=\"0\" autostart=\"0\"></embed>";
             } else {
@@ -1053,10 +1062,9 @@ class Enclosure
         $types_wmedia = ['application/asx', 'application/x-mplayer2', 'audio/x-ms-wma', 'audio/x-ms-wax', 'video/x-ms-asf-plugin', 'video/x-ms-asf', 'video/x-ms-wm', 'video/x-ms-wmv', 'video/x-ms-wvx']; // Windows Media
         $types_mp3 = ['audio/mp3', 'audio/x-mp3', 'audio/mpeg', 'audio/x-mpeg']; // MP3
 
-        if ($this->get_type() !== null) {
-            $type = strtolower($this->type);
-        } else {
-            $type = null;
+        $type = $this->get_type();
+        if ($type !== null) {
+            $type = strtolower($type);
         }
 
         // If we encounter an unsupported mime-type, check the file extension and guess intelligently.

+ 53 - 22
lib/simplepie/simplepie/src/File.php

@@ -57,7 +57,7 @@ class File implements Response
      */
     public $status_code = 0;
 
-    /** @var int Number of redirect that were already performed during this request sequence. */
+    /** @var non-negative-int Number of redirect that were already performed during this request sequence. */
     public $redirects = 0;
 
     /** @var ?string */
@@ -91,7 +91,7 @@ class File implements Response
         if (function_exists('idn_to_ascii')) {
             $parsed = \SimplePie\Misc::parse_url($url);
             if ($parsed['authority'] !== '' && !ctype_print($parsed['authority'])) {
-                $authority = \idn_to_ascii($parsed['authority'], \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46);
+                $authority = (string) \idn_to_ascii($parsed['authority'], \IDNA_NONTRANSITIONAL_TO_ASCII, \INTL_IDNA_VARIANT_UTS46);
                 $url = \SimplePie\Misc::compress_parse_url($parsed['scheme'], $authority, $parsed['path'], $parsed['query'], null);
             }
         }
@@ -102,7 +102,7 @@ class File implements Response
         $this->useragent = $useragent;
         if (preg_match('/^http(s)?:\/\//i', $url)) {
             if ($useragent === null) {
-                $useragent = ini_get('user_agent');
+                $useragent = (string) ini_get('user_agent');
                 $this->useragent = $useragent;
             }
             if (!is_array($headers)) {
@@ -127,7 +127,7 @@ class File implements Response
                 curl_setopt($fp, CURLOPT_URL, $url);
                 curl_setopt($fp, CURLOPT_HEADER, 1);
                 curl_setopt($fp, CURLOPT_RETURNTRANSFER, 1);
-                // curl_setopt($fp, CURLOPT_FAILONERROR, 1); // FreshRSS removed to retrieve headers even on HTTP errors
+                curl_setopt($fp, CURLOPT_FAILONERROR, 1);
                 curl_setopt($fp, CURLOPT_TIMEOUT, $timeout);
                 curl_setopt($fp, CURLOPT_CONNECTTIMEOUT, $timeout);
                 // curl_setopt($fp, CURLOPT_REFERER, \SimplePie\Misc::url_remove_credentials($url)); // FreshRSS removed
@@ -138,10 +138,9 @@ class File implements Response
                 }
 
                 $responseHeaders = curl_exec($fp);
-                if (curl_errno($fp) === 23 || curl_errno($fp) === 61) {
+                if (curl_errno($fp) === CURLE_WRITE_ERROR || curl_errno($fp) === CURLE_BAD_CONTENT_ENCODING) {
                     $this->error = 'cURL error ' . curl_errno($fp) . ': ' . curl_error($fp); // FreshRSS
-                    $this->status_code = curl_getinfo($fp, CURLINFO_HTTP_CODE); // FreshRSS
-                    $this->on_http_response($responseHeaders);
+                    $this->on_http_response();
                     $this->error = null; // FreshRSS
                     curl_setopt($fp, CURLOPT_ENCODING, 'none');
                     $responseHeaders = curl_exec($fp);
@@ -150,15 +149,17 @@ class File implements Response
                 if (curl_errno($fp)) {
                     $this->error = 'cURL error ' . curl_errno($fp) . ': ' . curl_error($fp);
                     $this->success = false;
-                    $this->on_http_response($responseHeaders);
+                    $this->on_http_response();
                 } else {
-                    $this->on_http_response($responseHeaders);
+                    $this->on_http_response();
                     // Use the updated url provided by curl_getinfo after any redirects.
                     if ($info = curl_getinfo($fp)) {
                         $this->url = $info['url'];
                     }
+                    // For PHPStan: We already checked that error did not occur.
+                    assert(is_array($info) && $info['redirect_count'] >= 0);
                     curl_close($fp);
-                    $responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders($responseHeaders, $info['redirect_count'] + 1);
+                    $responseHeaders = \SimplePie\HTTP\Parser::prepareHeaders((string) $responseHeaders, $info['redirect_count'] + 1);
                     $parser = new \SimplePie\HTTP\Parser($responseHeaders, true);
                     if ($parser->parse()) {
                         $this->set_headers($parser->headers);
@@ -167,6 +168,11 @@ class File implements Response
                         if ((in_array($this->status_code, [300, 301, 302, 303, 307]) || $this->status_code > 307 && $this->status_code < 400) && ($locationHeader = $this->get_header_line('location')) !== '' && $this->redirects < $redirects) {
                             $this->redirects++;
                             $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
+                            if ($location === false) {
+                                $this->error = "Invalid redirect location, trying to base “{$locationHeader}” onto “{$url}”";
+                                $this->success = false;
+                                return;
+                            }
                             $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
                             $this->__construct($location, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
                             return;
@@ -175,10 +181,15 @@ class File implements Response
                 }
             } else {
                 $this->method = \SimplePie\SimplePie::FILE_SOURCE_REMOTE | \SimplePie\SimplePie::FILE_SOURCE_FSOCKOPEN;
-                $url_parts = parse_url($url);
+                if (($url_parts = parse_url($url)) === false) {
+                    throw new \InvalidArgumentException('Malformed URL: ' . $url);
+                }
+                if (!isset($url_parts['host'])) {
+                    throw new \InvalidArgumentException('Missing hostname: ' . $url);
+                }
                 $socket_host = $url_parts['host'];
                 if (isset($url_parts['scheme']) && strtolower($url_parts['scheme']) === 'https') {
-                    $socket_host = "ssl://$url_parts[host]";
+                    $socket_host = 'ssl://' . $socket_host;
                     $url_parts['port'] = 443;
                 }
                 if (!isset($url_parts['port'])) {
@@ -188,7 +199,7 @@ class File implements Response
                 if (!$fp) {
                     $this->error = 'fsockopen error: ' . $errstr;
                     $this->success = false;
-                    $this->on_http_response(false);
+                    $this->on_http_response();
                 } else {
                     stream_set_timeout($fp, $timeout);
                     if (isset($url_parts['path'])) {
@@ -229,15 +240,21 @@ class File implements Response
                             $this->set_headers($parser->headers);
                             $this->body = $parser->body;
                             $this->status_code = $parser->status_code;
-                            $this->on_http_response($responseHeaders);
+                            $this->on_http_response();
                             if ((in_array($this->status_code, [300, 301, 302, 303, 307]) || $this->status_code > 307 && $this->status_code < 400) && ($locationHeader = $this->get_header_line('location')) !== '' && $this->redirects < $redirects) {
                                 $this->redirects++;
                                 $location = \SimplePie\Misc::absolutize_url($locationHeader, $url);
                                 $this->permanentUrlMutable = $this->permanentUrlMutable && ($this->status_code == 301 || $this->status_code == 308);
+                                if ($location === false) {
+                                    $this->error = "Invalid redirect location, trying to base “{$locationHeader}” onto “{$url}”";
+                                    $this->success = false;
+                                    return;
+                                }
                                 $this->__construct($location, $timeout, $redirects, $headers, $useragent, $force_fsockopen, $curl_options);
                                 return;
                             }
                             if (($contentEncodingHeader = $this->get_header_line('content-encoding')) !== '') {
+                                assert($this->body !== null); // For PHPStan // FreshRSS
                                 // Hey, we act dumb elsewhere, so let's do that here too
                                 switch (strtolower(trim($contentEncodingHeader, "\x09\x0A\x0D\x20"))) {
                                     case 'gzip':
@@ -271,12 +288,12 @@ class File implements Response
                         } else {
                             $this->error = 'Could not parse'; // FreshRSS
                             $this->success = false; // FreshRSS
-                            $this->on_http_response($responseHeaders);
+                            $this->on_http_response();
                         }
                     } else {
                         $this->error = 'fsocket timed out';
                         $this->success = false;
-                        $this->on_http_response($responseHeaders);
+                        $this->on_http_response();
                     }
                     fclose($fp);
                 }
@@ -291,22 +308,23 @@ class File implements Response
                 $this->body = $filebody;
                 $this->status_code = 200;
             }
-            $this->on_http_response($filebody);
+            $this->on_http_response();
         }
         if ($this->success) {
-            // (Leading) whitespace may cause XML parsing errors so we trim it,
-            // but we must not trim \x00 to avoid breaking BOM or multibyte characters
-            $this->body = trim($this->body, " \n\r\t\v");
+            assert($this->body !== null); // For PHPStan
+            // Leading whitespace may cause XML parsing errors (XML declaration cannot be preceded by anything other than BOM) so we trim it.
+            // Note that unlike built-in `trim` function’s default settings, we do not trim `\x00` to avoid breaking characters in UTF-16 or UTF-32 encoded strings.
+            // We also only do that when the whitespace is followed by `<`, so that we do not break e.g. UTF-16LE encoded whitespace like `\n\x00` in half.
+            $this->body = preg_replace('/^[ \n\r\t\v]+</', '<', $this->body);
         }
     }
 
     /**
      * Event to allow inheriting classes to e.g. log the HTTP responses.
      * Triggered just after an HTTP response is received.
-     * @param string|false $response The raw HTTP response headers and body, or false in case of failure (as returned by curl_exec()).
      * FreshRSS.
      */
-    protected function on_http_response(string|false $response): void
+    protected function on_http_response(): void
     {
     }
 
@@ -343,6 +361,19 @@ class File implements Response
         return $this->parsed_headers[strtolower($name)] ?? [];
     }
 
+    public function with_header(string $name, $value)
+    {
+        $this->maybe_update_headers();
+        $new = clone $this;
+
+        $newHeader = [
+            strtolower($name) => (array) $value,
+        ];
+        $new->set_headers($newHeader + $this->get_headers());
+
+        return $new;
+    }
+
     public function get_header_line(string $name): string
     {
         $this->maybe_update_headers();

+ 8 - 7
lib/simplepie/simplepie/src/Gzdecode.php

@@ -187,10 +187,10 @@ class Gzdecode
             // MTIME
             $mtime = substr($this->compressed_data, $this->position, 4);
             // Reverse the string if we're on a big-endian arch because l is the only signed long and is machine endianness
-            if (current(unpack('S', "\x00\x01")) === 1) {
+            if (current((array) unpack('S', "\x00\x01")) === 1) {
                 $mtime = strrev($mtime);
             }
-            $this->MTIME = current(unpack('l', $mtime));
+            $this->MTIME = current((array) unpack('l', $mtime));
             $this->position += 4;
 
             // Get the XFL (eXtra FLags)
@@ -211,7 +211,7 @@ class Gzdecode
                 }
 
                 // Get the length of the extra field
-                $len = current(unpack('v', substr($this->compressed_data, $this->position, 2)));
+                $len = current((array) unpack('v', substr($this->compressed_data, $this->position, 2)));
                 $this->position += 2;
 
                 // Check the length of the string is still valid
@@ -263,7 +263,7 @@ class Gzdecode
                 $this->min_compressed_size += $len + 2;
                 if ($this->compressed_size >= $this->min_compressed_size) {
                     // Read the CRC
-                    $crc = current(unpack('v', substr($this->compressed_data, $this->position, 2)));
+                    $crc = current((array) unpack('v', substr($this->compressed_data, $this->position, 2)));
 
                     // Check the CRC matches
                     if ((crc32(substr($this->compressed_data, 0, $this->position)) & 0xFFFF) === $crc) {
@@ -277,14 +277,15 @@ class Gzdecode
             }
 
             // Decompress the actual data
-            if (($this->data = gzinflate(substr($this->compressed_data, $this->position, -8))) === false) {
+            if (($data = gzinflate(substr($this->compressed_data, $this->position, -8))) === false) {
                 return false;
             }
 
+            $this->data = $data;
             $this->position = $this->compressed_size - 8;
 
             // Check CRC of data
-            $crc = current(unpack('V', substr($this->compressed_data, $this->position, 4)));
+            $crc = current((array) unpack('V', substr($this->compressed_data, $this->position, 4)));
             $this->position += 4;
             /*if (extension_loaded('hash') && sprintf('%u', current(unpack('V', hash('crc32b', $this->data)))) !== sprintf('%u', $crc))
             {
@@ -292,7 +293,7 @@ class Gzdecode
             }*/
 
             // Check ISIZE of data
-            $isize = current(unpack('V', substr($this->compressed_data, $this->position, 4)));
+            $isize = current((array) unpack('V', substr($this->compressed_data, $this->position, 4)));
             $this->position += 4;
             if (sprintf('%u', strlen($this->data) & 0xFFFFFFFF) !== sprintf('%u', $isize)) {
                 return false;

+ 1 - 1
lib/simplepie/simplepie/src/HTTP/FileClient.php

@@ -68,7 +68,7 @@ final class FileClient implements Client
             throw new ClientException($th->getMessage(), $th->getCode(), $th);
         }
 
-        if (!$file->success && $file->get_status_code() === 0) {
+        if ($file->error !== null && $file->get_status_code() === 0) {
             throw new ClientException($file->error);
         }
 

+ 36 - 11
lib/simplepie/simplepie/src/HTTP/Parser.php

@@ -234,6 +234,36 @@ class Parser
         $this->state = self::STATE_NEW_LINE;
     }
 
+    private function add_header(string $name, string $value): void
+    {
+        if ($this->psr7Compatible) {
+            // For PHPStan: should be enforced by template parameter but PHPStan is not smart enough.
+            /** @var array<string, non-empty-array<string>> */
+            $headers = &$this->headers;
+            $headers[$name][] = $value;
+        } else {
+            // For PHPStan: should be enforced by template parameter but PHPStan is not smart enough.
+            /** @var array<string, string>) */
+            $headers = &$this->headers;
+            $headers[$name] .= ', ' . $value;
+        }
+    }
+
+    private function replace_header(string $name, string $value): void
+    {
+        if ($this->psr7Compatible) {
+            // For PHPStan: should be enforced by template parameter but PHPStan is not smart enough.
+            /** @var array<string, non-empty-array<string>> */
+            $headers = &$this->headers;
+            $headers[$name] = [$value];
+        } else {
+            // For PHPStan: should be enforced by template parameter but PHPStan is not smart enough.
+            /** @var array<string, string>) */
+            $headers = &$this->headers;
+            $headers[$name] = $value;
+        }
+    }
+
     /**
      * Deal with a new line, shifting data around as needed
      * @return void
@@ -245,17 +275,9 @@ class Parser
             $this->name = strtolower($this->name);
             // We should only use the last Content-Type header. c.f. issue #1
             if (isset($this->headers[$this->name]) && $this->name !== 'content-type') {
-                if ($this->psr7Compatible) {
-                    $this->headers[$this->name][] = $this->value;
-                } else {
-                    $this->headers[$this->name] .= ', ' . $this->value;
-                }
+                $this->add_header($this->name, $this->value);
             } else {
-                if ($this->psr7Compatible) {
-                    $this->headers[$this->name] = [$this->value];
-                } else {
-                    $this->headers[$this->name] = $this->value;
-                }
+                $this->replace_header($this->name, $this->value);
             }
         }
         $this->name = '';
@@ -449,6 +471,9 @@ class Parser
             }
 
             $length = hexdec(trim($matches[1]));
+            // For PHPStan: this will only be float when larger than PHP_INT_MAX.
+            // But even on 32-bit systems, it would mean 2GiB chunk, which sounds unlikely.
+            \assert(\is_int($length), "Length needs to be shorter than PHP_INT_MAX");
             if ($length === 0) {
                 // Ignore trailer headers
                 $this->state = self::STATE_EMIT;
@@ -475,7 +500,7 @@ class Parser
      * Prepare headers (take care of proxies headers)
      *
      * @param string  $headers Raw headers
-     * @param int $count   Redirection count. Default to 1.
+     * @param non-negative-int $count Redirection count. Default to 1.
      *
      * @return string
      */

+ 9 - 1
lib/simplepie/simplepie/src/HTTP/Psr7Response.php

@@ -58,7 +58,10 @@ final class Psr7Response implements Response
 
     public function get_headers(): array
     {
-        return $this->response->getHeaders();
+        // The filtering is probably redundant but let’s make PHPStan happy.
+        return array_filter($this->response->getHeaders(), function (array $header): bool {
+            return count($header) >= 1;
+        });
     }
 
     public function has_header(string $name): bool
@@ -66,6 +69,11 @@ final class Psr7Response implements Response
         return $this->response->hasHeader($name);
     }
 
+    public function with_header(string $name, $value)
+    {
+        return new self($this->response->withHeader($name, $value), $this->permanent_url, $this->requested_url);
+    }
+
     public function get_header(string $name): array
     {
         return $this->response->getHeader($name);

+ 21 - 4
lib/simplepie/simplepie/src/HTTP/RawTextResponse.php

@@ -27,6 +27,11 @@ final class RawTextResponse implements Response
      */
     private $permanent_url;
 
+    /**
+     * @var array<non-empty-array<string>>
+     */
+    private $headers = [];
+
     /**
      * @var string
      */
@@ -56,22 +61,34 @@ final class RawTextResponse implements Response
 
     public function get_headers(): array
     {
-        return [];
+        return $this->headers;
     }
 
     public function has_header(string $name): bool
     {
-        return false;
+        return isset($this->headers[strtolower($name)]);
     }
 
     public function get_header(string $name): array
     {
-        return [];
+        return isset($this->headers[strtolower($name)]) ? $this->headers[$name] : [];
+    }
+
+    public function with_header(string $name, $value)
+    {
+        $new = clone $this;
+
+        $newHeader = [
+            strtolower($name) => (array) $value,
+        ];
+        $new->headers = $newHeader + $this->headers;
+
+        return $new;
     }
 
     public function get_header_line(string $name): string
     {
-        return '';
+        return isset($this->headers[strtolower($name)]) ? implode(", ", $this->headers[$name]) : '';
     }
 
     public function get_body_content(): string

+ 15 - 1
lib/simplepie/simplepie/src/HTTP/Response.php

@@ -95,7 +95,7 @@ interface Response
      *         }
      *     }
      *
-     * @return string[][] Returns an associative array of the message's headers.
+     * @return array<non-empty-array<string>> Returns an associative array of the message's headers.
      *     Each key MUST be a header name, and each value MUST be an array of
      *     strings for that header.
      */
@@ -127,6 +127,20 @@ interface Response
      */
     public function get_header(string $name): array;
 
+    /**
+     * Return an instance with the provided value replacing the specified header.
+     *
+     * This method MUST be implemented in such a way as to retain the
+     * immutability of the message, and MUST return an instance that has the
+     * new and/or updated header and value.
+     *
+     * @param string $name Case-insensitive header field name.
+     * @param string|non-empty-array<string> $value Header value(s).
+     * @return static
+     * @throws \InvalidArgumentException for invalid header names or values.
+     */
+    public function with_header(string $name, $value);
+
     /**
      * Retrieves a comma-separated string of the values for a single header.
      *

+ 24 - 11
lib/simplepie/simplepie/src/IRI.php

@@ -107,7 +107,7 @@ class IRI
      */
     public function __toString()
     {
-        return $this->get_iri();
+        return (string) $this->get_iri();
     }
 
     /**
@@ -119,8 +119,9 @@ class IRI
      */
     public function __set(string $name, $value)
     {
-        if (method_exists($this, 'set_' . $name)) {
-            call_user_func([$this, 'set_' . $name], $value);
+        $callable = [$this, 'set_' . $name];
+        if (is_callable($callable)) {
+            call_user_func($callable, $value);
         } elseif (
             $name === 'iauthority'
             || $name === 'iuserinfo'
@@ -195,8 +196,9 @@ class IRI
      */
     public function __unset(string $name)
     {
-        if (method_exists($this, 'set_' . $name)) {
-            call_user_func([$this, 'set_' . $name], '');
+        $callable = [$this, 'set_' . $name];
+        if (is_callable($callable)) {
+            call_user_func($callable, '');
         }
     }
 
@@ -292,7 +294,13 @@ class IRI
      * Parse an IRI into scheme/authority/path/query/fragment segments
      *
      * @param string $iri
-     * @return array<string, mixed>|false
+     * @return array{
+     *   scheme: string|null,
+     *   authority: string|null,
+     *   path: string,
+     *   query: string|null,
+     *   fragment: string|null,
+     * }|false
      */
     protected function parse_iri(string $iri)
     {
@@ -367,9 +375,11 @@ class IRI
     {
         // Normalize as many pct-encoded sections as possible
         $string = preg_replace_callback('/(?:%[A-Fa-f0-9]{2})+/', [$this, 'remove_iunreserved_percent_encoded'], $string);
+        \assert(\is_string($string), "For PHPStan: Should not occur, the regex is valid");
 
         // Replace invalid percent characters
         $string = preg_replace('/%(?![A-Fa-f0-9]{2})/', '%25', $string);
+        \assert(\is_string($string), "For PHPStan: Should not occur, the regex is valid");
 
         // Add unreserved and % to $extra_chars (the latter is safe because all
         // pct-encoded sections are now valid).
@@ -484,7 +494,7 @@ class IRI
      * Removes sequences of percent encoded bytes that represent UTF-8
      * encoded characters in iunreserved
      *
-     * @param array<int, string> $match PCRE match
+     * @param array{string} $match PCRE match, a capture group #0 consisting of a sequence of valid percent-encoded bytes
      * @return string Replacement
      */
     protected function remove_iunreserved_percent_encoded(array $match)
@@ -590,7 +600,8 @@ class IRI
                     }
                 } else {
                     for ($j = $start; $j <= $i; $j++) {
-                        $string .= chr(hexdec($bytes[$j]));
+                        // Cast for PHPStan, this will always be a number between 0 and 0xFF so hexdec will return int.
+                        $string .= chr((int) hexdec($bytes[$j]));
                     }
                 }
             }
@@ -782,7 +793,9 @@ class IRI
 
         $remaining = $authority;
         if (($iuserinfo_end = strrpos($remaining, '@')) !== false) {
-            $iuserinfo = substr($remaining, 0, $iuserinfo_end);
+            // Cast for PHPStan on PHP < 8.0. It does not detect that
+            // the range is not flipped so substr cannot return false.
+            $iuserinfo = (string) substr($remaining, 0, $iuserinfo_end);
             $remaining = substr($remaining, $iuserinfo_end + 1);
         } else {
             $iuserinfo = null;
@@ -885,7 +898,7 @@ class IRI
         if ($port === null) {
             $this->port = null;
             return true;
-        } elseif (strspn($port, '0123456789') === strlen($port)) {
+        } elseif (strspn((string) $port, '0123456789') === strlen((string) $port)) {
             $this->port = (int) $port;
             $this->scheme_normalization();
             return true;
@@ -1026,7 +1039,7 @@ class IRI
      */
     public function get_uri()
     {
-        return $this->to_uri($this->get_iri());
+        return $this->to_uri((string) $this->get_iri());
     }
 
     /**

+ 17 - 12
lib/simplepie/simplepie/src/Item.php

@@ -161,11 +161,12 @@ class Item implements RegistryAware
      * @see \SimplePie\SimplePie::sanitize()
      * @param string $data Data to sanitize
      * @param int-mask-of<SimplePie::CONSTRUCT_*> $type
-     * @param string|null $base Base URL to resolve URLs against
+     * @param string $base Base URL to resolve URLs against
      * @return string Sanitized data
      */
-    public function sanitize(string $data, int $type, ?string $base = '')
+    public function sanitize(string $data, int $type, string $base = '')
     {
+        // This really returns string|false but changing encoding is uncommon and we are going to deprecate it, so let’s just lie to PHPStan in the interest of cleaner annotations.
         return $this->feed->sanitize($data, $type, $base);
     }
 
@@ -347,7 +348,7 @@ class Item implements RegistryAware
      * Uses `<media:thumbnail>`
      *
      *
-     * @return array<string, mixed>|null
+     * @return array{url: string, height?: string, width?: string, time?: string}|null
      */
     public function get_thumbnail()
     {
@@ -632,7 +633,7 @@ class Item implements RegistryAware
      * @since Beta 2 (previously called `get_item_date` since 0.8)
      *
      * @param string $date_format Supports any PHP date format from {@see http://php.net/date} (empty for the raw data)
-     * @return int|string|null
+     * @return ($date_format is 'U' ? ?int : ?string)
      */
     public function get_date(string $date_format = 'j F Y, g:i a')
     {
@@ -687,7 +688,7 @@ class Item implements RegistryAware
      * {@see get_gmdate}
      *
      * @param string $date_format Supports any PHP date format from {@see http://php.net/date} (empty for the raw data)
-     * @return int|string|null
+     * @return ($date_format is 'U' ? ?int : ?string)
      */
     public function get_updated_date(string $date_format = 'j F Y, g:i a')
     {
@@ -730,12 +731,16 @@ class Item implements RegistryAware
      * @since 1.0
      *
      * @param string $date_format Supports any PHP date format from {@see http://php.net/strftime} (empty for the raw data)
-     * @return int|string|null
+     * @return string|null|false see `strftime` for when this can return `false`
      */
     public function get_local_date(string $date_format = '%c')
     {
-        if (!$date_format) {
-            return $this->sanitize($this->get_date(''), \SimplePie\SimplePie::CONSTRUCT_TEXT);
+        if ($date_format === '') {
+            if (($raw_date = $this->get_date('')) === null) {
+                return null;
+            }
+
+            return $this->sanitize($raw_date, \SimplePie\SimplePie::CONSTRUCT_TEXT);
         } elseif (($date = $this->get_date('U')) !== null && $date !== false) {
             return strftime($date_format, $date);
         }
@@ -748,7 +753,7 @@ class Item implements RegistryAware
      *
      * @see get_date
      * @param string $date_format Supports any PHP date format from {@see http://php.net/date}
-     * @return int|string|null
+     * @return string|null
      */
     public function get_gmdate(string $date_format = 'j F Y, g:i a')
     {
@@ -765,7 +770,7 @@ class Item implements RegistryAware
      *
      * @see get_updated_date
      * @param string $date_format Supports any PHP date format from {@see http://php.net/date}
-     * @return int|string|null
+     * @return string|null
      */
     public function get_updated_gmdate(string $date_format = 'j F Y, g:i a')
     {
@@ -867,8 +872,8 @@ class Item implements RegistryAware
                     } else {
                         $this->data['links'][\SimplePie\SimplePie::IANA_LINK_RELATIONS_REGISTRY . $key] = &$this->data['links'][$key];
                     }
-                } elseif (substr($key, 0, 41) === \SimplePie\SimplePie::IANA_LINK_RELATIONS_REGISTRY) {
-                    $this->data['links'][substr($key, 41)] = &$this->data['links'][$key];
+                } elseif (substr((string) $key, 0, 41) === \SimplePie\SimplePie::IANA_LINK_RELATIONS_REGISTRY) {
+                    $this->data['links'][substr((string) $key, 41)] = &$this->data['links'][$key];
                 }
                 $this->data['links'][$key] = array_unique($this->data['links'][$key]);
             }

+ 33 - 10
lib/simplepie/simplepie/src/Locator.php

@@ -118,6 +118,8 @@ class Locator implements RegistryAware
      */
     public function find(int $type = \SimplePie\SimplePie::LOCATOR_ALL, ?array &$working = null)
     {
+        assert($this->registry !== null);
+
         if ($this->is_feed($this->file)) {
             return $this->file;
         }
@@ -162,6 +164,8 @@ class Locator implements RegistryAware
      */
     public function is_feed(Response $file, bool $check_html = false)
     {
+        assert($this->registry !== null);
+
         if (Misc::is_remote_uri($file->get_final_requested_uri())) {
             $sniffer = $this->registry->create(Content\Type\Sniffer::class, [$file]);
             $sniffed = $sniffer->get_type();
@@ -185,6 +189,8 @@ class Locator implements RegistryAware
      */
     public function get_base()
     {
+        assert($this->registry !== null);
+
         if ($this->dom === null) {
             throw new \SimplePie\Exception('DOMDocument not found, unable to use locator');
         }
@@ -229,6 +235,8 @@ class Locator implements RegistryAware
      */
     protected function search_elements_by_tag(string $name, array &$done, array $feeds)
     {
+        assert($this->registry !== null);
+
         if ($this->dom === null) {
             throw new \SimplePie\Exception('DOMDocument not found, unable to use locator');
         }
@@ -279,6 +287,8 @@ class Locator implements RegistryAware
      */
     public function get_links()
     {
+        assert($this->registry !== null);
+
         if ($this->dom === null) {
             throw new \SimplePie\Exception('DOMDocument not found, unable to use locator');
         }
@@ -317,10 +327,14 @@ class Locator implements RegistryAware
     }
 
     /**
+     * Extracts first `link` element with given `rel` attribute inside the `head` element.
+     *
      * @return string|null
      */
     public function get_rel_link(string $rel)
     {
+        assert($this->registry !== null);
+
         if ($this->dom === null) {
             throw new \SimplePie\Exception('DOMDocument not found, unable to use '.
                                           'locator');
@@ -331,9 +345,10 @@ class Locator implements RegistryAware
         }
 
         $xpath = new \DOMXpath($this->dom);
-        $query = '//a[@rel and @href] | //link[@rel and @href]';
-        foreach ($xpath->query($query) as $link) {
-            /** @var \DOMElement $link */
+        $query = '(//head)[1]/link[@rel and @href]';
+        /** @var \DOMNodeList<\DOMElement> */
+        $queryResult = $xpath->query($query);
+        foreach ($queryResult as $link) {
             $href = trim($link->getAttribute('href'));
             $parsed = $this->registry->call(Misc::class, 'parse_url', [$href]);
             if ($parsed['scheme'] === '' ||
@@ -361,6 +376,7 @@ class Locator implements RegistryAware
                 }
             }
         }
+
         return null;
     }
 
@@ -435,16 +451,23 @@ class Locator implements RegistryAware
      */
     private function get_http_client(): Client
     {
+        assert($this->registry !== null);
+
         if ($this->http_client === null) {
+            $options = [
+                'timeout' => $this->timeout,
+                'redirects' => 5,
+                'force_fsockopen' => $this->force_fsockopen,
+                'curl_options' => $this->curl_options,
+            ];
+
+            if ($this->useragent !== null) {
+                $options['useragent'] = $this->useragent;
+            }
+
             return new FileClient(
                 $this->registry,
-                [
-                    'timeout' => $this->timeout,
-                    'redirects' => 5,
-                    'useragent' => $this->useragent,
-                    'force_fsockopen' => $this->force_fsockopen,
-                    'curl_options' => $this->curl_options,
-                ]
+                $options
             );
         }
 

+ 19 - 16
lib/simplepie/simplepie/src/Misc.php

@@ -71,7 +71,7 @@ class Misc
      * @deprecated since SimplePie 1.3, use DOMDocument instead (parsing HTML with regex is bad!)
      * @param string $realname Element name (including namespace prefix if applicable)
      * @param string $string HTML document
-     * @return array<array{tag: string, self_closing: bool, attribs: array<string, array{data: string}>, content: string}>
+     * @return array<array{tag: string, self_closing: bool, attribs: array<string, array{data: string}>, content?: string}>
      */
     public static function get_element(string $realname, string $string)
     {
@@ -92,11 +92,11 @@ class Misc
                 }
                 $return[$i]['attribs'] = [];
                 if (isset($matches[$i][2][0]) && preg_match_all('/[\x09\x0A\x0B\x0C\x0D\x20]+([^\x09\x0A\x0B\x0C\x0D\x20\x2F\x3E][^\x09\x0A\x0B\x0C\x0D\x20\x2F\x3D\x3E]*)(?:[\x09\x0A\x0B\x0C\x0D\x20]*=[\x09\x0A\x0B\x0C\x0D\x20]*(?:"([^"]*)"|\'([^\']*)\'|([^\x09\x0A\x0B\x0C\x0D\x20\x22\x27\x3E][^\x09\x0A\x0B\x0C\x0D\x20\x3E]*)?))?/', ' ' . $matches[$i][2][0] . ' ', $attribs, PREG_SET_ORDER)) {
-                    for ($j = 0, $total_attribs = count($attribs); $j < $total_attribs; $j++) {
-                        if (count($attribs[$j]) === 2) {
-                            $attribs[$j][2] = $attribs[$j][1];
+                    foreach ($attribs as $attrib) {
+                        if (count($attrib) === 2) {
+                            $attrib[2] = $attrib[1];
                         }
-                        $return[$i]['attribs'][strtolower($attribs[$j][1])]['data'] = Misc::entities_decode(end($attribs[$j]));
+                        $return[$i]['attribs'][strtolower($attrib[1])]['data'] = Misc::entities_decode(end($attrib));
                     }
                 }
             }
@@ -261,7 +261,8 @@ class Misc
     {
         $integer = hexdec($match[1]);
         if ($integer >= 0x41 && $integer <= 0x5A || $integer >= 0x61 && $integer <= 0x7A || $integer >= 0x30 && $integer <= 0x39 || $integer === 0x2D || $integer === 0x2E || $integer === 0x5F || $integer === 0x7E) {
-            return chr($integer);
+            // Cast for PHPStan, the value would only be float when above PHP_INT_MAX, which would not go in this branch.
+            return chr((int) $integer);
         }
 
         return strtoupper($match[0]);
@@ -287,7 +288,7 @@ class Misc
      * @param string $data Raw data in $input encoding
      * @param string $input Encoding of $data
      * @param string $output Encoding you want
-     * @return string|bool False if we can't convert it
+     * @return string|false False if we can't convert it
      */
     public static function change_encoding(string $data, string $input, string $output)
     {
@@ -391,7 +392,8 @@ class Misc
     public static function encoding(string $charset)
     {
         // Normalization from UTS #22
-        switch (strtolower(preg_replace('/(?:[^a-zA-Z0-9]+|([^0-9])0+)/', '\1', $charset))) {
+        // Cast for PHPStan, the regex should not fail.
+        switch (strtolower((string) preg_replace('/(?:[^a-zA-Z0-9]+|([^0-9])0+)/', '\1', $charset))) {
             case 'adobestandardencoding':
             case 'csadobestandardencoding':
                 return 'Adobe-Standard-Encoding';
@@ -2097,7 +2099,7 @@ class Misc
         header('Cache-Control: must-revalidate');
         header('Expires: ' . gmdate('D, d M Y H:i:s', time() + 604800) . ' GMT'); // 7 days
 
-        $body = <<<END
+        $body = <<<JS
 function embed_quicktime(type, bgcolor, width, height, link, placeholder, loop) {
 	if (placeholder != '') {
 		document.writeln('<embed type="'+type+'" style="cursor:hand; cursor:pointer;" href="'+link+'" src="'+placeholder+'" width="'+width+'" height="'+height+'" autoplay="false" target="myself" controller="false" loop="'+loop+'" scale="aspect" bgcolor="'+bgcolor+'" pluginspage="http://www.apple.com/quicktime/download/"></embed>');
@@ -2118,7 +2120,7 @@ function embed_flv(width, height, link, placeholder, loop, player) {
 function embed_wmedia(width, height, link) {
 	document.writeln('<embed type="application/x-mplayer2" src="'+link+'" autosize="1" width="'+width+'" height="'+height+'" showcontrols="1" showstatusbar="0" showdisplay="0" autostart="0"></embed>');
 }
-END;
+JS;
         echo $body;
     }
 
@@ -2162,7 +2164,8 @@ END;
         $info = 'SimplePie ' . \SimplePie\SimplePie::VERSION . ' Build ' . static::get_build() . "\n";
         $info .= 'PHP ' . PHP_VERSION . "\n";
         if ($sp->error() !== null) {
-            $info .= 'Error occurred: ' . $sp->error() . "\n";
+            // TODO: Remove cast with multifeeds.
+            $info .= 'Error occurred: ' . implode(', ', (array) $sp->error()) . "\n";
         } else {
             $info .= "No error found.\n";
         }
@@ -2176,12 +2179,9 @@ END;
                         $info .= '      Version ' . PCRE_VERSION . "\n";
                         break;
                     case 'curl':
-                        $version = curl_version();
+                        $version = (array) curl_version();
                         $info .= '      Version ' . $version['version'] . "\n";
                         break;
-                    case 'mbstring':
-                        $info .= '      Overloading: ' . mb_get_info('func_overload') . "\n";
-                        break;
                     case 'iconv':
                         $info .= '      Version ' . ICONV_VERSION . "\n";
                         break;
@@ -2212,7 +2212,10 @@ END;
      */
     public static function url_remove_credentials(string $url)
     {
-        return preg_replace('#^(https?://)[^/:@]+:[^/:@]+@#i', '$1', $url);
+        // Cast for PHPStan: I do not think this can fail.
+        // The regex is valid and there should be no backtracking.
+        // https://github.com/phpstan/phpstan/issues/11547
+        return (string) preg_replace('#^(https?://)[^/:@]+:[^/:@]+@#i', '$1', $url);
     }
 }
 

+ 10 - 4
lib/simplepie/simplepie/src/Net/IPv6.php

@@ -23,7 +23,7 @@ class IPv6
     /**
      * Uncompresses an IPv6 address
      *
-     * RFC 4291 allows you to compress concecutive zero pieces in an address to
+     * RFC 4291 allows you to compress consecutive zero pieces in an address to
      * '::'. This method expects a valid IPv6 address and expands the '::' to
      * the required number of zero pieces.
      *
@@ -83,7 +83,7 @@ class IPv6
     /**
      * Compresses an IPv6 address
      *
-     * RFC 4291 allows you to compress concecutive zero pieces in an address to
+     * RFC 4291 allows you to compress consecutive zero pieces in an address to
      * '::'. This method expects a valid IPv6 address and compresses consecutive
      * zero pieces to '::'.
      *
@@ -101,7 +101,7 @@ class IPv6
         $ip_parts = self::split_v6_v4($ip);
 
         // Replace all leading zeros
-        $ip_parts[0] = preg_replace('/(^|:)0+([0-9])/', '\1\2', $ip_parts[0]);
+        $ip_parts[0] = (string) preg_replace('/(^|:)0+([0-9])/', '\1\2', $ip_parts[0]);
 
         // Find bunches of zeros
         if (preg_match_all('/(?:^|:)(?:0(?::|$))+/', $ip_parts[0], $matches, PREG_OFFSET_CAPTURE)) {
@@ -114,6 +114,7 @@ class IPv6
                 }
             }
 
+            assert($pos !== null, 'For PHPStan: Since the regex matched, there is at least one match. And because the pattern is non-empty, the loop will always end with $pos ≥ 1.');
             $ip_parts[0] = substr_replace($ip_parts[0], '::', $pos, $max);
         }
 
@@ -140,6 +141,7 @@ class IPv6
     {
         if (strpos($ip, '.') !== false) {
             $pos = strrpos($ip, ':');
+            assert($pos !== false, 'For PHPStan: IPv6 address must contain colon, since split_v6_v4 is only ever called after uncompress.');
             $ipv6_part = substr($ip, 0, $pos);
             $ipv4_part = substr($ip, $pos + 1);
             return [$ipv6_part, $ipv4_part];
@@ -182,7 +184,11 @@ class IPv6
 
                 // Check the value is valid
                 $value = hexdec($ipv6_part);
-                if (dechex($value) !== strtolower($ipv6_part) || $value < 0 || $value > 0xFFFF) {
+                if ($value < 0 || $value > 0xFFFF) {
+                    return false;
+                }
+                assert(is_int($value), 'For PHPStan: $value is only float when $ipv6_part > PHP_INT_MAX');
+                if (dechex($value) !== strtolower($ipv6_part)) {
                     return false;
                 }
             }

+ 6 - 3
lib/simplepie/simplepie/src/Parse/Date.php

@@ -544,7 +544,7 @@ class Date
      * Array of user-added callback methods
      *
      * @access private
-     * @var array<callable(string): string|false>
+     * @var array<callable(string): (int|false)>
      */
     public $user = [];
 
@@ -602,12 +602,15 @@ class Date
     {
         foreach ($this->user as $method) {
             if (($returned = call_user_func($method, $date)) !== false) {
-                return $returned;
+                return (int) $returned;
             }
         }
 
         foreach ($this->built_in as $method) {
-            if (($returned = call_user_func([$this, $method], $date)) !== false) {
+            // TODO: we should really check this in constructor but that would require private properties.
+            /** @var callable(string): (int|false) */
+            $callable = [$this, $method];
+            if (($returned = call_user_func($callable, $date)) !== false) {
                 return $returned;
             }
         }

+ 32 - 28
lib/simplepie/simplepie/src/Parser.php

@@ -72,6 +72,7 @@ class Parser implements RegistryAware
             // and a list of entries without an h-feed wrapper are both valid.
             $query = '//*[contains(concat(" ", @class, " "), " h-feed ") or '.
                 'contains(concat(" ", @class, " "), " h-entry ")]';
+            /** @var \DOMNodeList<\DOMElement> $result */
             $result = $xpath->query($query);
             if ($result->length !== 0) {
                 return $this->parse_microformats($data, $url);
@@ -146,12 +147,11 @@ class Parser implements RegistryAware
                 rewind($stream)) {
                 //Parse by chunks not to use too much memory
                 do {
-                    $stream_data = fread($stream, 1048576);
-                    // NB: At some point between PHP 7.3 and 7.4, the signature for `fread()` has changed
-                    // from returning `string` to returning `string|false`, hence the falsy check:
-                    if (!xml_parse($xml, $stream_data == false ? '' : $stream_data, feof($stream))) {
+                    $stream_data = (string) fread($stream, 1048576);
+
+                    if (!xml_parse($xml, $stream_data, feof($stream))) {
                         $this->error_code = xml_get_error_code($xml);
-                        $this->error_string = xml_error_string($this->error_code);
+                        $this->error_string = xml_error_string($this->error_code) ?: "Unknown";
                         $return = false;
                         break;
                     }
@@ -173,7 +173,7 @@ class Parser implements RegistryAware
         $xml->xml($data);
         while (@$xml->read()) {
             switch ($xml->nodeType) {
-                case constant('XMLReader::END_ELEMENT'):
+                case \XMLReader::END_ELEMENT:
                     if ($xml->namespaceURI !== '') {
                         $tagName = $xml->namespaceURI . $this->separator . $xml->localName;
                     } else {
@@ -181,7 +181,7 @@ class Parser implements RegistryAware
                     }
                     $this->tag_close(null, $tagName);
                     break;
-                case constant('XMLReader::ELEMENT'):
+                case \XMLReader::ELEMENT:
                     $empty = $xml->isEmptyElement;
                     if ($xml->namespaceURI !== '') {
                         $tagName = $xml->namespaceURI . $this->separator . $xml->localName;
@@ -202,9 +202,9 @@ class Parser implements RegistryAware
                         $this->tag_close(null, $tagName);
                     }
                     break;
-                case constant('XMLReader::TEXT'):
+                case \XMLReader::TEXT:
 
-                case constant('XMLReader::CDATA'):
+                case \XMLReader::CDATA:
                     $this->cdata(null, $xml->value);
                     break;
             }
@@ -290,14 +290,14 @@ class Parser implements RegistryAware
                 $this->xml_base_explicit[] = true;
             }
         } else {
-            $this->xml_base[] = end($this->xml_base);
+            $this->xml_base[] = end($this->xml_base) ?: '';
             $this->xml_base_explicit[] = end($this->xml_base_explicit);
         }
 
         if (isset($attribs[\SimplePie\SimplePie::NAMESPACE_XML]['lang'])) {
             $this->xml_lang[] = $attribs[\SimplePie\SimplePie::NAMESPACE_XML]['lang'];
         } else {
-            $this->xml_lang[] = end($this->xml_lang);
+            $this->xml_lang[] = end($this->xml_lang) ?: '';
         }
 
         if ($this->current_xhtml_construct >= 0) {
@@ -428,6 +428,9 @@ class Parser implements RegistryAware
      */
     private function parse_microformats(string &$data, string $url): bool
     {
+        // For PHPStan, we already check that in call site.
+        \assert(function_exists('Mf2\parse'));
+        \assert(function_exists('Mf2\fetch'));
         $feed_title = '';
         $feed_author = null;
         $author_cache = [];
@@ -513,23 +516,24 @@ class Parser implements RegistryAware
                         if (isset($author_cache[$author])) {
                             $author = $author_cache[$author];
                         } else {
-                            $mf = \Mf2\fetch($author);
-                            foreach ($mf['items'] as $hcard) {
-                                // Only interested in an h-card by itself in this case.
-                                if (!in_array('h-card', $hcard['type'])) {
-                                    continue;
-                                }
-                                // It must have a url property matching what we fetched.
-                                if (!isset($hcard['properties']['url']) ||
-                                        !(in_array($author, $hcard['properties']['url']))) {
-                                    continue;
+                            if ($mf = \Mf2\fetch($author)) {
+                                foreach ($mf['items'] as $hcard) {
+                                    // Only interested in an h-card by itself in this case.
+                                    if (!in_array('h-card', $hcard['type'])) {
+                                        continue;
+                                    }
+                                    // It must have a url property matching what we fetched.
+                                    if (!isset($hcard['properties']['url']) ||
+                                            !(in_array($author, $hcard['properties']['url']))) {
+                                        continue;
+                                    }
+                                    // Save parse_hcard the trouble of finding the correct url.
+                                    $hcard['properties']['url'][0] = $author;
+                                    // Cache this h-card for the next h-entry to check.
+                                    $author_cache[$author] = $this->parse_hcard($hcard);
+                                    $author = $author_cache[$author];
+                                    break;
                                 }
-                                // Save parse_hcard the trouble of finding the correct url.
-                                $hcard['properties']['url'][0] = $author;
-                                // Cache this h-card for the next h-entry to check.
-                                $author_cache[$author] = $this->parse_hcard($hcard);
-                                $author = $author_cache[$author];
-                                break;
                             }
                         }
                     }
@@ -650,7 +654,7 @@ class Parser implements RegistryAware
     private static function set_doctype(string $data): string
     {
         // Strip DOCTYPE except if containing an [internal subset]
-        $data = preg_replace('/^\\s*<!DOCTYPE\\s[^>\\[\\]]*>\s*/', '', $data);
+        $data = preg_replace('/^\\s*<!DOCTYPE\\s[^>\\[\\]]*>\s*/', '', $data) ?? $data;
         // Declare HTML entities only if no remaining DOCTYPE
         $doctype = preg_match('/^\\s*<!DOCTYPE\\s/', $data) ? '' : self::declare_html_entities();
         return $doctype . $data;

+ 27 - 1
lib/simplepie/simplepie/src/Registry.php

@@ -7,6 +7,7 @@ declare(strict_types=1);
 
 namespace SimplePie;
 
+use InvalidArgumentException;
 use SimplePie\Content\Type\Sniffer;
 use SimplePie\Parse\Date;
 use SimplePie\XML\Declaration\Parser as DeclarationParser;
@@ -161,9 +162,13 @@ class Registry
             return null;
         }
 
+        // For PHPStan: values in $default should be subtypes of keys.
+        /** @var class-string<T> */
         $class = $this->default[$type];
 
         if (array_key_exists($type, $this->classes)) {
+            // For PHPStan: values in $classes should be subtypes of keys.
+            /** @var class-string<T> */
             $class = $this->classes[$type];
         }
 
@@ -181,11 +186,20 @@ class Registry
     public function &create($type, array $parameters = [])
     {
         $class = $this->get_class($type);
+        if ($class === null) {
+            throw new InvalidArgumentException(sprintf(
+                '%s(): Argument #1 ($type) "%s" not found in class list.',
+                __METHOD__,
+                $type
+            ), 1);
+        }
 
         if (!method_exists($class, '__construct')) {
             $instance = new $class();
         } else {
             $reflector = new \ReflectionClass($class);
+            // For PHPStan: $class is T.
+            /** @var T */
             $instance = $reflector->newInstanceArgs($parameters);
         }
 
@@ -195,6 +209,7 @@ class Registry
             trigger_error(sprintf('Using the method "set_registry()" without implementing "%s" is deprecated since SimplePie 1.8.0, implement "%s" in "%s".', RegistryAware::class, RegistryAware::class, $class), \E_USER_DEPRECATED);
             $instance->set_registry($this);
         }
+
         return $instance;
     }
 
@@ -209,6 +224,13 @@ class Registry
     public function &call($type, string $method, array $parameters = [])
     {
         $class = $this->get_class($type);
+        if ($class === null) {
+            throw new InvalidArgumentException(sprintf(
+                '%s(): Argument #1 ($type) "%s" not found in class list.',
+                __METHOD__,
+                $type
+            ), 1);
+        }
 
         if (in_array($class, $this->legacy)) {
             switch ($type) {
@@ -217,6 +239,8 @@ class Registry
                     // Cache::create() methods in PHP < 8.0.
                     // No longer supported as of PHP 8.0.
                     if ($method === 'get_handler') {
+                        // Fixing this PHPStan error breaks CacheTest::testDirectOverrideLegacy()
+                        /** @phpstan-ignore argument.type */
                         $result = @call_user_func_array([$class, 'create'], $parameters);
                         return $result;
                     }
@@ -224,7 +248,9 @@ class Registry
             }
         }
 
-        $result = call_user_func_array([$class, $method], $parameters);
+        $callable = [$class, $method];
+        assert(is_callable($callable), 'For PHPstan');
+        $result = call_user_func_array($callable, $parameters);
         return $result;
     }
 }

+ 62 - 18
lib/simplepie/simplepie/src/Sanitize.php

@@ -60,7 +60,7 @@ class Sanitize implements RegistryAware
     public $enable_cache = true;
     /** @var string */
     public $cache_location = './cache';
-    /** @var string */
+    /** @var string&(callable(string): string) */
     public $cache_name_function = 'md5';
 
     /**
@@ -144,7 +144,7 @@ class Sanitize implements RegistryAware
     }
 
     /**
-     * @param string|NameFilter $cache_name_function
+     * @param (string&(callable(string): string))|NameFilter $cache_name_function
      * @param class-string<Cache> $cache_class
      * @return void
      */
@@ -168,7 +168,7 @@ class Sanitize implements RegistryAware
         // BC: $cache_name_function could be a callable as string
         if (is_string($cache_name_function)) {
             // trigger_error(sprintf('Providing $cache_name_function as string in "%s()" is deprecated since SimplePie 1.8.0, provide as "%s" instead.', __METHOD__, NameFilter::class), \E_USER_DEPRECATED);
-            $this->cache_name_function = (string) $cache_name_function;
+            $this->cache_name_function = $cache_name_function;
 
             $cache_name_function = new CallableNameFilter($cache_name_function);
         }
@@ -220,7 +220,7 @@ class Sanitize implements RegistryAware
     }
 
     /**
-     * @param string[]|string $tags
+     * @param string[]|string|false $tags Set a list of tags to strip, or set empty string to use default tags, or false to strip nothing.
      * @return void
      */
     public function strip_htmltags($tags = ['base', 'blink', 'body', 'doctype', 'embed', 'font', 'form', 'frame', 'frameset', 'html', 'iframe', 'input', 'marquee', 'meta', 'noscript', 'object', 'param', 'script', 'style'])
@@ -411,7 +411,7 @@ class Sanitize implements RegistryAware
     /**
      * @param int-mask-of<SimplePie::CONSTRUCT_*> $type
      * @param string $base
-     * @return string|bool|string[]
+     * @return string Sanitized data; false if output encoding is changed to something other than UTF-8 and conversion fails
      */
     public function sanitize(string $data, int $type, string $base = '')
     {
@@ -436,6 +436,10 @@ class Sanitize implements RegistryAware
                 $document = new \DOMDocument();
                 $document->encoding = 'UTF-8';
 
+                // PHPStan seems to have trouble resolving int-mask because bitwise
+                // operators are used when operators are used when passing this parameter.
+                // https://github.com/phpstan/phpstan/issues/9384
+                /** @var int-mask-of<SimplePie::CONSTRUCT_*> $type */
                 $data = $this->preprocess($data, $type);
 
                 set_error_handler([Misc::class, 'silence_errors']);
@@ -446,10 +450,13 @@ class Sanitize implements RegistryAware
 
                 // Strip comments
                 if ($this->strip_comments) {
+                    /** @var \DOMNodeList<\DOMComment> */
                     $comments = $xpath->query('//comment()');
 
                     foreach ($comments as $comment) {
-                        $comment->parentNode->removeChild($comment);
+                        $parentNode = $comment->parentNode;
+                        assert($parentNode !== null, 'For PHPStan, comment must have a parent');
+                        $parentNode->removeChild($comment);
                     }
                 }
 
@@ -521,18 +528,23 @@ class Sanitize implements RegistryAware
                 }
 
                 // Get content node
-                $div = $document->getElementsByTagName('body')->item(0)->firstChild;
+                $div = null;
+                if (($item = $document->getElementsByTagName('body')->item(0)) !== null) {
+                    $div = $item->firstChild;
+                }
                 // Finally, convert to a HTML string
-                $data = trim($document->saveHTML($div));
+                $data = trim((string) $document->saveHTML($div));
 
                 if ($this->remove_div) {
                     $data = preg_replace('/^<div' . \SimplePie\SimplePie::PCRE_XML_ATTRIBUTE . '>/', '', $data);
-                    $data = preg_replace('/<\/div>$/', '', $data);
+                    // Cast for PHPStan, it is unable to validate a non-literal regex above.
+                    $data = preg_replace('/<\/div>$/', '', (string) $data);
                 } else {
                     $data = preg_replace('/^<div' . \SimplePie\SimplePie::PCRE_XML_ATTRIBUTE . '>/', '<div>', $data);
                 }
 
-                $data = str_replace('</source>', '', $data);
+                // Cast for PHPStan, it is unable to validate a non-literal regex above.
+                $data = str_replace('</source>', '', (string) $data);
             }
 
             if ($type & \SimplePie\SimplePie::CONSTRUCT_IRI) {
@@ -547,6 +559,8 @@ class Sanitize implements RegistryAware
             }
 
             if ($this->output_encoding !== 'UTF-8') {
+                // This really returns string|false but changing encoding is uncommon and we are going to deprecate it, so let’s just lie to PHPStan in the interest of cleaner annotations.
+                /** @var string */
                 $data = $this->registry->call(Misc::class, 'change_encoding', [$data, 'UTF-8', $this->output_encoding]);
             }
         }
@@ -632,6 +646,14 @@ class Sanitize implements RegistryAware
     protected function strip_tag(string $tag, DOMDocument $document, DOMXPath $xpath, int $type)
     {
         $elements = $xpath->query('body//' . $tag);
+
+        if ($elements === false) {
+            throw new \SimplePie\Exception(sprintf(
+                '%s(): Possibly malformed expression, check argument #1 ($tag)',
+                __METHOD__
+            ), 1);
+        }
+
         if ($this->encode_instead_of_strip) {
             foreach ($elements as $element) {
                 $fragment = $document->createDocumentFragment();
@@ -639,7 +661,7 @@ class Sanitize implements RegistryAware
                 // For elements which aren't script or style, include the tag itself
                 if (!in_array($tag, ['script', 'style'])) {
                     $text = '<' . $tag;
-                    if ($element->hasAttributes()) {
+                    if ($element->attributes !== null) {
                         $attrs = [];
                         foreach ($element->attributes as $name => $attr) {
                             $value = $attr->value;
@@ -665,21 +687,26 @@ class Sanitize implements RegistryAware
 
                 $number = $element->childNodes->length;
                 for ($i = $number; $i > 0; $i--) {
-                    $child = $element->childNodes->item(0);
-                    $fragment->appendChild($child);
+                    if (($child = $element->childNodes->item(0)) !== null) {
+                        $fragment->appendChild($child);
+                    }
                 }
 
                 if (!in_array($tag, ['script', 'style'])) {
                     $fragment->appendChild(new \DOMText('</' . $tag . '>'));
                 }
 
-                $element->parentNode->replaceChild($fragment, $element);
+                if (($parentNode = $element->parentNode) !== null) {
+                    $parentNode->replaceChild($fragment, $element);
+                }
             }
 
             return;
         } elseif (in_array($tag, ['script', 'style'])) {
             foreach ($elements as $element) {
-                $element->parentNode->removeChild($element);
+                if (($parentNode = $element->parentNode) !== null) {
+                    $parentNode->removeChild($element);
+                }
             }
 
             return;
@@ -688,11 +715,14 @@ class Sanitize implements RegistryAware
                 $fragment = $document->createDocumentFragment();
                 $number = $element->childNodes->length;
                 for ($i = $number; $i > 0; $i--) {
-                    $child = $element->childNodes->item(0);
-                    $fragment->appendChild($child);
+                    if (($child = $element->childNodes->item(0)) !== null) {
+                        $fragment->appendChild($child);
+                    }
                 }
 
-                $element->parentNode->replaceChild($fragment, $element);
+                if (($parentNode = $element->parentNode) !== null) {
+                    $parentNode->replaceChild($fragment, $element);
+                }
             }
         }
     }
@@ -704,6 +734,13 @@ class Sanitize implements RegistryAware
     {
         $elements = $xpath->query('//*[@' . $attrib . ']');
 
+        if ($elements === false) {
+            throw new \SimplePie\Exception(sprintf(
+                '%s(): Possibly malformed expression, check argument #1 ($attrib)',
+                __METHOD__
+            ), 1);
+        }
+
         /** @var \DOMElement $element */
         foreach ($elements as $element) {
             $element->removeAttribute($attrib);
@@ -717,6 +754,13 @@ class Sanitize implements RegistryAware
     {
         $elements = $xpath->query('//*[@' . $attrib . ']');
 
+        if ($elements === false) {
+            throw new \SimplePie\Exception(sprintf(
+                '%s(): Possibly malformed expression, check argument #1 ($attrib)',
+                __METHOD__
+            ), 1);
+        }
+
         /** @var \DOMElement $element */
         foreach ($elements as $element) {
             $element->setAttribute('data-sanitized-' . $attrib, $element->getAttribute($attrib));

+ 35 - 35
lib/simplepie/simplepie/src/SimplePie.php

@@ -546,7 +546,7 @@ class SimplePie
     public $cache_location = './cache';
 
     /**
-     * @var string Function that creates the cache filename
+     * @var string&(callable(string): string) Function that creates the cache filename
      * @see SimplePie::set_cache_name_function()
      * @access private
      */
@@ -1451,10 +1451,10 @@ class SimplePie
      *
      * @deprecated since SimplePie 1.8.0, use {@see set_cache_namefilter()} instead
      *
-     * @param ?callable(string): string $function Callback function
+     * @param (string&(callable(string): string))|null $function Callback function
      * @return void
      */
-    public function set_cache_name_function(?callable $function = null)
+    public function set_cache_name_function(?string $function = null)
     {
         // trigger_error(sprintf('"%s()" is deprecated since SimplePie 1.8.0, please use "SimplePie\SimplePie::set_cache_namefilter()" instead.', __METHOD__), \E_USER_DEPRECATED);
 
@@ -1510,7 +1510,7 @@ class SimplePie
     }
 
     /**
-     * @param string[]|string|false $tags Set a list of tags to strip, or set empty string to use default tags or false, to strip nothing.
+     * @param string[]|string|false $tags Set a list of tags to strip, or set empty string to use default tags, or false to strip nothing.
      * @return void
      */
     public function strip_htmltags($tags = '', ?bool $encode = null)
@@ -1699,7 +1699,7 @@ class SimplePie
                         ],
                         '',
                         $stream_data
-                    )
+                    ) ?? ''
                 );
             }
             fclose($stream);
@@ -1749,11 +1749,13 @@ class SimplePie
 
         // Pass whatever was set with config options over to the sanitizer.
         // Pass the classes in for legacy support; new classes should use the registry instead
+        $cache = $this->registry->get_class(Cache::class);
+        \assert($cache !== null, 'Cache must be defined');
         $this->sanitize->pass_cache_data(
             $this->enable_cache,
             $this->cache_location,
             $this->cache_namefilter,
-            $this->registry->get_class(Cache::class),
+            $cache,
             $this->cache
         );
 
@@ -1925,7 +1927,7 @@ class SimplePie
      * If the data is already cached, attempt to fetch it from there instead
      *
      * @param Base|DataCache|false $cache Cache handler, or false to not load from the cache
-     * @return array{array<string, string>, string}|array{}|bool Returns true if the data was loaded from the cache, or an array of HTTP headers and sniffed type
+     * @return array{array<string, string>, string}|bool Returns true if the data was loaded from the cache, or an array of HTTP headers and sniffed type
      */
     protected function fetch_data(&$cache)
     {
@@ -1999,7 +2001,7 @@ class SimplePie
                             $this->status_code = $file->get_status_code();
                         } catch (ClientException $th) {
                             $this->check_modified = false;
-                            $this->status_code = $th->getCode(); // FreshRSS https://github.com/simplepie/simplepie/pull/905
+                            $this->status_code = 0;
 
                             if ($this->force_cache_fallback) {
                                 $this->data['cache_expiration_time'] = \SimplePie\HTTP\Utils::negociate_cache_expiration_time($this->data['headers'] ?? [], $this->cache_duration, $this->cache_duration_min, $this->cache_duration_max); // FreshRSS
@@ -2093,7 +2095,6 @@ class SimplePie
                 } catch (ClientException $th) {
                     // If the file connection has an error, set SimplePie::error to that and quit
                     $this->error = $th->getMessage();
-                    $this->status_code = $th->getCode(); // FreshRSS https://github.com/simplepie/simplepie/pull/905
 
                     return !empty($this->data);
                 }
@@ -2141,6 +2142,8 @@ class SimplePie
                         // and a list of entries without an h-feed wrapper are both valid.
                         $query = '//*[contains(concat(" ", @class, " "), " h-feed ") or '.
                             'contains(concat(" ", @class, " "), " h-entry ")]';
+
+                        /** @var \DOMNodeList<\DOMElement> $result */
                         $result = $xpath->query($query);
                         $microformats = $result->length !== 0;
                     }
@@ -2151,11 +2154,10 @@ class SimplePie
                         $this->all_discovered_feeds
                     );
                     if ($microformats) {
-                        if ($hub = $locate->get_rel_link('hub')) {
-                            $self = $locate->get_rel_link('self');
-                            if ($file instanceof File) {
-                                $this->store_links($file, $hub, $self);
-                            }
+                        $hub = $locate->get_rel_link('hub');
+                        $self = $locate->get_rel_link('self');
+                        if ($hub || $self) {
+                            $file = $this->store_links($file, $hub, $self);
                         }
                         // Push the current file onto all_discovered feeds so the user can
                         // be shown this as one of the options.
@@ -2621,13 +2623,14 @@ class SimplePie
      * @access private
      * @see Sanitize::sanitize()
      * @param string $data Data to sanitize
-     * @param self::CONSTRUCT_* $type One of the self::CONSTRUCT_* constants
+     * @param int-mask-of<SimplePie::CONSTRUCT_*> $type
      * @param string $base Base URL to resolve URLs against
      * @return string Sanitized data
      */
     public function sanitize(string $data, int $type, string $base = '')
     {
         try {
+            // This really returns string|false but changing encoding is uncommon and we are going to deprecate it, so let’s just lie to PHPStan in the interest of cleaner annotations.
             return $this->sanitize->sanitize($data, $type, $base);
         } catch (SimplePieException $e) {
             if (!$this->enable_exceptions) {
@@ -3318,7 +3321,7 @@ class SimplePie
      * @since Beta 2
      * @param int $start Index to start at
      * @param int $end Number of items to return. 0 for all items after `$start`
-     * @return Item[]|null List of {@see Item} objects
+     * @return Item[] List of {@see Item} objects
      */
     public function get_items(int $start = 0, int $end = 0)
     {
@@ -3434,8 +3437,8 @@ class SimplePie
 
         $class = get_class($this);
         $trace = debug_backtrace();
-        $file = $trace[0]['file'];
-        $line = $trace[0]['line'];
+        $file = $trace[0]['file'] ?? '';
+        $line = $trace[0]['line'] ?? '';
         throw new SimplePieException("Call to undefined method $class::$method() in $file on line $line");
     }
 
@@ -3522,28 +3525,25 @@ class SimplePie
      *
      * There is no way to find PuSH links in the body of a microformats feed,
      * so they are added to the headers when found, to be used later by get_links.
-     * @param string $hub
-     * @param string $self
      */
-    private function store_links(File &$file, string $hub, string $self): void
+    private function store_links(Response $file, ?string $hub, ?string $self): Response
     {
-        if (isset($file->headers['link']) && preg_match('/rel=hub/', $file->headers['link'])) {
-            return;
+        $linkHeaderLine = $file->get_header_line('link');
+        $linkHeader = $file->get_header('link');
+
+        if ($hub && !preg_match('/rel=hub/', $linkHeaderLine)) {
+            $linkHeader[] = '<'.$hub.'>; rel=hub';
         }
 
-        if ($hub) {
-            if (isset($file->headers['link'])) {
-                if ($file->headers['link'] !== '') {
-                    $file->headers['link'] = ', ';
-                }
-            } else {
-                $file->headers['link'] = '';
-            }
-            $file->headers['link'] .= '<'.$hub.'>; rel=hub';
-            if ($self) {
-                $file->headers['link'] .= ', <'.$self.'>; rel=self';
-            }
+        if ($self && !preg_match('/rel=self/', $linkHeaderLine)) {
+            $linkHeader[] = '<'.$self.'>; rel=self';
         }
+
+        if (count($linkHeader) > 0) {
+            $file = $file->with_header('link', $linkHeader);
+        }
+
+        return $file;
     }
 
     /**

+ 2 - 0
lib/simplepie/simplepie/src/Source.php

@@ -373,6 +373,8 @@ class Source implements RegistryAware
 
             $keys = array_keys($this->data['links']);
             foreach ($keys as $key) {
+                $key = (string) $key;
+
                 if ($this->registry->call(Misc::class, 'is_isegment_nz_nc', [$key])) {
                     if (isset($this->data['links'][\SimplePie\SimplePie::IANA_LINK_RELATIONS_REGISTRY . $key])) {
                         $this->data['links'][\SimplePie\SimplePie::IANA_LINK_RELATIONS_REGISTRY . $key] = array_merge($this->data['links'][$key], $this->data['links'][\SimplePie\SimplePie::IANA_LINK_RELATIONS_REGISTRY . $key]);

+ 4 - 2
lib/simplepie/simplepie/src/XML/Declaration/Parser.php

@@ -204,7 +204,8 @@ class Parser
 
     public function version_value(): void
     {
-        if ($this->version = $this->get_value()) {
+        if ($version = $this->get_value()) {
+            $this->version = $version;
             $this->skip_whitespace();
             if ($this->has_data()) {
                 $this->state = self::STATE_ENCODING_NAME;
@@ -240,7 +241,8 @@ class Parser
 
     public function encoding_value(): void
     {
-        if ($this->encoding = $this->get_value()) {
+        if ($encoding = $this->get_value()) {
+            $this->encoding = $encoding;
             $this->skip_whitespace();
             if ($this->has_data()) {
                 $this->state = self::STATE_STANDALONE_NAME;