From b817598f5711b784c05579e473847e1030b4f75e Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Fri, 1 Aug 2025 23:27:35 +0200 Subject: 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 --- lib/simplepie/simplepie/.gitignore | 1 - lib/simplepie/simplepie/phpstan.neon.dist | 7 +- lib/simplepie/simplepie/src/Cache.php | 9 ++- .../simplepie/src/Cache/CallableNameFilter.php | 5 +- lib/simplepie/simplepie/src/Cache/File.php | 2 +- lib/simplepie/simplepie/src/Cache/MySQL.php | 4 +- lib/simplepie/simplepie/src/Enclosure.php | 26 ++++--- lib/simplepie/simplepie/src/File.php | 75 ++++++++++++++------ lib/simplepie/simplepie/src/Gzdecode.php | 15 ++-- lib/simplepie/simplepie/src/HTTP/FileClient.php | 2 +- lib/simplepie/simplepie/src/HTTP/Parser.php | 47 ++++++++++--- lib/simplepie/simplepie/src/HTTP/Psr7Response.php | 10 ++- .../simplepie/src/HTTP/RawTextResponse.php | 25 +++++-- lib/simplepie/simplepie/src/HTTP/Response.php | 16 ++++- lib/simplepie/simplepie/src/IRI.php | 35 +++++++--- lib/simplepie/simplepie/src/Item.php | 29 ++++---- lib/simplepie/simplepie/src/Locator.php | 43 +++++++++--- lib/simplepie/simplepie/src/Misc.php | 35 +++++----- lib/simplepie/simplepie/src/Net/IPv6.php | 14 ++-- lib/simplepie/simplepie/src/Parse/Date.php | 9 ++- lib/simplepie/simplepie/src/Parser.php | 60 ++++++++-------- lib/simplepie/simplepie/src/Registry.php | 28 +++++++- lib/simplepie/simplepie/src/Sanitize.php | 80 +++++++++++++++++----- lib/simplepie/simplepie/src/SimplePie.php | 70 +++++++++---------- lib/simplepie/simplepie/src/Source.php | 2 + .../simplepie/src/XML/Declaration/Parser.php | 6 +- 26 files changed, 446 insertions(+), 209 deletions(-) (limited to 'lib/simplepie') diff --git a/lib/simplepie/simplepie/.gitignore b/lib/simplepie/simplepie/.gitignore index 94dd5ecc9..805c1f9b9 100644 --- a/lib/simplepie/simplepie/.gitignore +++ b/lib/simplepie/simplepie/.gitignore @@ -1,7 +1,6 @@ *sandbox* demo/cache/* SimplePie.compiled.php -bin/ vendor/ composer.lock phpstan.neon diff --git a/lib/simplepie/simplepie/phpstan.neon.dist b/lib/simplepie/simplepie/phpstan.neon.dist index d922910f9..da1d19fc6 100644 --- a/lib/simplepie/simplepie/phpstan.neon.dist +++ b/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+\.$)' diff --git a/lib/simplepie/simplepie/src/Cache.php b/lib/simplepie/simplepie/src/Cache.php index 00c606707..587fb1522 100644 --- a/lib/simplepie/simplepie/src/Cache.php +++ b/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']); } diff --git a/lib/simplepie/simplepie/src/Cache/CallableNameFilter.php b/lib/simplepie/simplepie/src/Cache/CallableNameFilter.php index 6a128a3db..e95fa2917 100644 --- a/lib/simplepie/simplepie/src/Cache/CallableNameFilter.php +++ b/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; diff --git a/lib/simplepie/simplepie/src/Cache/File.php b/lib/simplepie/simplepie/src/Cache/File.php index 95afdc898..110a77c43 100644 --- a/lib/simplepie/simplepie/src/Cache/File.php +++ b/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; } diff --git a/lib/simplepie/simplepie/src/Cache/MySQL.php b/lib/simplepie/simplepie/src/Cache/MySQL.php index dedcd354b..73699ad75 100644 --- a/lib/simplepie/simplepie/src/Cache/MySQL.php +++ b/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; diff --git a/lib/simplepie/simplepie/src/Enclosure.php b/lib/simplepie/simplepie/src/Enclosure.php index 9d27aa4b7..89231c7f9 100644 --- a/lib/simplepie/simplepie/src/Enclosure.php +++ b/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 .= "get_link().'?file_extension=.'.$this->get_extension()) . "&autostart=false&repeat=$loop&showdigits=true&showfsbutton=false\">"; } 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 .= "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/\">"; @@ -1017,7 +1023,10 @@ class Enclosure // Windows Media elseif ($handler === 'wmedia') { - $height += 45; + if (is_numeric($height)) { + $height += 45; + } + if ($native) { $embed .= "get_link() . "\" autosize=\"1\" width=\"$width\" height=\"$height\" showcontrols=\"1\" showstatusbar=\"0\" showdisplay=\"0\" autostart=\"0\">"; } 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. diff --git a/lib/simplepie/simplepie/src/File.php b/lib/simplepie/simplepie/src/File.php index 93c943624..4a8cb157c 100644 --- a/lib/simplepie/simplepie/src/File.php +++ b/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]+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(); diff --git a/lib/simplepie/simplepie/src/Gzdecode.php b/lib/simplepie/simplepie/src/Gzdecode.php index f331d1dc7..2c9770783 100644 --- a/lib/simplepie/simplepie/src/Gzdecode.php +++ b/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; diff --git a/lib/simplepie/simplepie/src/HTTP/FileClient.php b/lib/simplepie/simplepie/src/HTTP/FileClient.php index 2cbae4d3b..90fefebb0 100644 --- a/lib/simplepie/simplepie/src/HTTP/FileClient.php +++ b/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); } diff --git a/lib/simplepie/simplepie/src/HTTP/Parser.php b/lib/simplepie/simplepie/src/HTTP/Parser.php index 4612bdb02..e9bcc4671 100644 --- a/lib/simplepie/simplepie/src/HTTP/Parser.php +++ b/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> */ + $headers = &$this->headers; + $headers[$name][] = $value; + } else { + // For PHPStan: should be enforced by template parameter but PHPStan is not smart enough. + /** @var array) */ + $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> */ + $headers = &$this->headers; + $headers[$name] = [$value]; + } else { + // For PHPStan: should be enforced by template parameter but PHPStan is not smart enough. + /** @var array) */ + $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 */ diff --git a/lib/simplepie/simplepie/src/HTTP/Psr7Response.php b/lib/simplepie/simplepie/src/HTTP/Psr7Response.php index 7a52c8ec6..418fddf52 100644 --- a/lib/simplepie/simplepie/src/HTTP/Psr7Response.php +++ b/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); diff --git a/lib/simplepie/simplepie/src/HTTP/RawTextResponse.php b/lib/simplepie/simplepie/src/HTTP/RawTextResponse.php index 1cb8fd46d..fee5e5372 100644 --- a/lib/simplepie/simplepie/src/HTTP/RawTextResponse.php +++ b/lib/simplepie/simplepie/src/HTTP/RawTextResponse.php @@ -27,6 +27,11 @@ final class RawTextResponse implements Response */ private $permanent_url; + /** + * @var array> + */ + 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 diff --git a/lib/simplepie/simplepie/src/HTTP/Response.php b/lib/simplepie/simplepie/src/HTTP/Response.php index f27515e33..cc5296758 100644 --- a/lib/simplepie/simplepie/src/HTTP/Response.php +++ b/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> 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 $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. * diff --git a/lib/simplepie/simplepie/src/IRI.php b/lib/simplepie/simplepie/src/IRI.php index 8543b2a52..7fc538cd4 100644 --- a/lib/simplepie/simplepie/src/IRI.php +++ b/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|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 $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()); } /** diff --git a/lib/simplepie/simplepie/src/Item.php b/lib/simplepie/simplepie/src/Item.php index 2b0201d77..c2f7460c6 100644 --- a/lib/simplepie/simplepie/src/Item.php +++ b/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 $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 `` * * - * @return array|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]); } diff --git a/lib/simplepie/simplepie/src/Locator.php b/lib/simplepie/simplepie/src/Locator.php index 2f06c3f99..30a7fe525 100644 --- a/lib/simplepie/simplepie/src/Locator.php +++ b/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 ); } diff --git a/lib/simplepie/simplepie/src/Misc.php b/lib/simplepie/simplepie/src/Misc.php index 42885db5c..a31c22bb2 100644 --- a/lib/simplepie/simplepie/src/Misc.php +++ b/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, content: string}> + * @return array, 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 = <<'); @@ -2118,7 +2120,7 @@ function embed_flv(width, height, link, placeholder, loop, player) { function embed_wmedia(width, height, link) { document.writeln(''); } -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); } } diff --git a/lib/simplepie/simplepie/src/Net/IPv6.php b/lib/simplepie/simplepie/src/Net/IPv6.php index 73ef7be1b..db931529f 100644 --- a/lib/simplepie/simplepie/src/Net/IPv6.php +++ b/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; } } diff --git a/lib/simplepie/simplepie/src/Parse/Date.php b/lib/simplepie/simplepie/src/Parse/Date.php index 4b533e70f..57318e0b4 100644 --- a/lib/simplepie/simplepie/src/Parse/Date.php +++ b/lib/simplepie/simplepie/src/Parse/Date.php @@ -544,7 +544,7 @@ class Date * Array of user-added callback methods * * @access private - * @var array + * @var array */ 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; } } diff --git a/lib/simplepie/simplepie/src/Parser.php b/lib/simplepie/simplepie/src/Parser.php index eb171d9dd..2db05b7a2 100644 --- a/lib/simplepie/simplepie/src/Parser.php +++ b/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*\\[\\]]*>\s*/', '', $data); + $data = preg_replace('/^\\s*\\[\\]]*>\s*/', '', $data) ?? $data; // Declare HTML entities only if no remaining DOCTYPE $doctype = preg_match('/^\\s* */ $class = $this->default[$type]; if (array_key_exists($type, $this->classes)) { + // For PHPStan: values in $classes should be subtypes of keys. + /** @var class-string */ $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; } } diff --git a/lib/simplepie/simplepie/src/Sanitize.php b/lib/simplepie/simplepie/src/Sanitize.php index 9af3a6b12..c8aa2dce6 100644 --- a/lib/simplepie/simplepie/src/Sanitize.php +++ b/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_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 $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 $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('/^/', '', $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('/^/', '
', $data); } - $data = str_replace('', '', $data); + // Cast for PHPStan, it is unable to validate a non-literal regex above. + $data = str_replace('', '', (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('')); } - $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)); diff --git a/lib/simplepie/simplepie/src/SimplePie.php b/lib/simplepie/simplepie/src/SimplePie.php index a61e7158a..b351b1215 100644 --- a/lib/simplepie/simplepie/src/SimplePie.php +++ b/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}|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}|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 $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; } /** diff --git a/lib/simplepie/simplepie/src/Source.php b/lib/simplepie/simplepie/src/Source.php index bd672f328..932fb84d9 100644 --- a/lib/simplepie/simplepie/src/Source.php +++ b/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]); diff --git a/lib/simplepie/simplepie/src/XML/Declaration/Parser.php b/lib/simplepie/simplepie/src/XML/Declaration/Parser.php index 0d5a38a4c..1fcd22b9f 100644 --- a/lib/simplepie/simplepie/src/XML/Declaration/Parser.php +++ b/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; -- cgit v1.2.3