diff --git a/lib/AbstractException.php b/lib/AbstractException.php
index 8aaff950..31623458 100644
--- a/lib/AbstractException.php
+++ b/lib/AbstractException.php
@@ -75,6 +75,7 @@ abstract class AbstractException extends \Exception {
"User/Exception.authFailed" => 10412,
"User/ExceptionAuthz.notAuthorized" => 10421,
"User/ExceptionSession.invalid" => 10431,
+ "Feed/Exception.internalError" => 10500,
"Feed/Exception.invalidCertificate" => 10501,
"Feed/Exception.invalidUrl" => 10502,
"Feed/Exception.maxRedirect" => 10503,
@@ -83,6 +84,7 @@ abstract class AbstractException extends \Exception {
"Feed/Exception.forbidden" => 10506,
"Feed/Exception.unauthorized" => 10507,
"Feed/Exception.transmissionError" => 10508,
+ "Feed/Exception.connectionFailed" => 10509,
"Feed/Exception.malformedXml" => 10511,
"Feed/Exception.xmlEntity" => 10512,
"Feed/Exception.subscriptionNotFound" => 10521,
diff --git a/lib/Feed.php b/lib/Feed.php
index 984b4493..e8e5dac2 100644
--- a/lib/Feed.php
+++ b/lib/Feed.php
@@ -100,7 +100,7 @@ class Feed {
$client->reader = $reader;
return $client;
} catch (PicoFeedException $e) {
- throw new Feed\Exception($url, $e);
+ throw new Feed\Exception($url, $e); // @codeCoverageIgnore
} catch (\GuzzleHttp\Exception\GuzzleException $e) {
throw new Feed\Exception($url, $e);
}
@@ -117,16 +117,10 @@ class Feed {
// Some feeds might use a different domain (eg: feedburner), so the site url is
// used instead of the feed's url.
$this->favicon = (new Favicon)->find($feed->siteUrl);
- // work around a PicoFeed memory leak
- libxml_use_internal_errors(false);
} catch (PicoFeedException $e) {
- // work around a PicoFeed memory leak
- libxml_use_internal_errors(false);
- throw new Feed\Exception($this->resource->getUrl(), $e);
- } catch (\GuzzleHttp\Exception\GuzzleException $e) {
- // work around a PicoFeed memory leak
- libxml_use_internal_errors(false);
throw new Feed\Exception($this->resource->getUrl(), $e);
+ } catch (\GuzzleHttp\Exception\GuzzleException $e) { // @codeCoverageIgnore
+ throw new Feed\Exception($this->resource->getUrl(), $e); // @codeCoverageIgnore
}
// PicoFeed does not provide valid ids when there is no id element. Its solution
diff --git a/lib/Feed/Exception.php b/lib/Feed/Exception.php
index b9d20560..00c76524 100644
--- a/lib/Feed/Exception.php
+++ b/lib/Feed/Exception.php
@@ -12,48 +12,32 @@ use GuzzleHttp\Exception\TooManyRedirectsException;
use PicoFeed\PicoFeedException;
class Exception extends \JKingWeb\Arsse\AbstractException {
+ const CURL_ERROR_MAP = [1=>"invalidUrl",3=>"invalidUrl",5=>"transmissionError","connectionFailed","connectionFailed","transmissionError","forbidden","unauthorized","transmissionError","transmissionError","transmissionError","transmissionError","connectionFailed","connectionFailed","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError","invalidUrl","transmissionError","transmissionError","transmissionError","transmissionError",28=>"timeout","transmissionError","transmissionError","transmissionError","transmissionError","transmissionError",35=>"invalidCertificate","transmissionError","transmissionError","transmissionError","transmissionError",45=>"transmissionError","unauthorized","maxRedirect",52=>"transmissionError","invalidCertificate","invalidCertificate","transmissionError","transmissionError",58=>"invalidCertificate","invalidCertificate","invalidCertificate","transmissionError","invalidUrl","transmissionError","invalidCertificate","transmissionError","invalidCertificate","forbidden","invalidUrl","forbidden","transmissionError",73=>"transmissionError","transmissionError",77=>"invalidCertificate","invalidUrl",90=>"invalidCertificate","invalidCertificate","transmissionError",94=>"unauthorized","transmissionError","connectionFailed"];
+ const HTTP_ERROR_MAP = [401=>"unauthorized",403=>"forbidden",404=>"invalidUrl",408=>"timeout",410=>"invalidUrl",414=>"invalidUrl",451=>"invalidUrl"];
+
public function __construct($url, \Throwable $e) {
if ($e instanceof BadResponseException) {
- switch ($e->getCode()) {
- case 401:
- $msgID = "unauthorized";
- break;
- case 403:
- $msgID = "forbidden";
- break;
- case 404:
- case 410:
- $msgID = "invalidUrl";
- break;
- case 508:
- $msgID = "tooManyRedirects";
- break;
- default:
- $msgID = "transmissionError";
- }
+ $msgID = self::HTTP_ERROR_MAP[$e->getCode()] ?? "transmissionError";
} elseif ($e instanceof TooManyRedirectsException) {
$msgID = "maxRedirect";
} elseif ($e instanceof GuzzleException) {
- $m = $e->getMessage();
- if (preg_match("/^Error creating resource:/", $m)) {
+ $msg = $e->getMessage();
+ if (preg_match("/^Error creating resource:/", $msg)) {
// PHP stream error; the class of error is ambiguous
- $msgID = "transmissionError"; // @codeCoverageIgnore
- } elseif (preg_match("/^cURL error 35:/", $m)) {
- $msgID = "invalidCertificate";
- } elseif (preg_match("/^cURL error 28:/", $m)) {
- $msgID = "timeout";
+ $msgID = "transmissionError";
+ } elseif (preg_match("/^cURL error (\d+):/", $msg, $match)) {
+ $msgID = self::CURL_ERROR_MAP[(int) $match[1]] ?? "internalError";
} else {
- var_export($m);
- exit;
+ $msgID = "internalError";
}
} elseif ($e instanceof PicoFeedException) {
$className = get_class($e);
// Convert the exception thrown by PicoFeed to the one to be thrown here.
$msgID = preg_replace('/^PicoFeed\\\(?:Client|Parser|Reader)\\\([A-Za-z]+)Exception$/', '$1', $className);
// If the message ID doesn't change then it's unknown.
- $msgID = ($msgID !== $className) ? lcfirst($msgID) : '';
+ $msgID = ($msgID !== $className) ? lcfirst($msgID) : "internalError";
} else {
- $msgID = get_class($e);
+ $msgID = "internalError";
}
parent::__construct($msgID, ['url' => $url], $e);
}
diff --git a/locale/en.php b/locale/en.php
index d66fddf3..c19ac94b 100644
--- a/locale/en.php
+++ b/locale/en.php
@@ -144,6 +144,7 @@ return [
other {Authenticated user is not authorized to perform the action "{action}" on behalf of {user}}
}',
'Exception.JKingWeb/Arsse/User/ExceptionSession.invalid' => 'Session with ID {0} does not exist',
+ 'Exception.JKingWeb/Arsse/Feed/Exception.internalError' => 'Could not download feed "{url}" because of an internal error which is probably a bug',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidCertificate' => 'Could not download feed "{url}" because its server is serving an invalid SSL certificate',
'Exception.JKingWeb/Arsse/Feed/Exception.invalidUrl' => 'Feed URL "{url}" is invalid',
'Exception.JKingWeb/Arsse/Feed/Exception.maxRedirect' => 'Could not download feed "{url}" because its server reached its maximum number of HTTP redirections',
@@ -152,6 +153,7 @@ return [
'Exception.JKingWeb/Arsse/Feed/Exception.forbidden' => 'Could not download feed "{url}" because you do not have permission to access it',
'Exception.JKingWeb/Arsse/Feed/Exception.unauthorized' => 'Could not download feed "{url}" because you provided insufficient or invalid credentials',
'Exception.JKingWeb/Arsse/Feed/Exception.transmissionError' => 'Could not download feed "{url}" because of a network error',
+ 'Exception.JKingWeb/Arsse/Feed/Exception.connectionFailed' => 'Could not download feed "{url}" because its server could not be reached',
'Exception.JKingWeb/Arsse/Feed/Exception.malformedXml' => 'Could not parse feed "{url}" because it is malformed',
'Exception.JKingWeb/Arsse/Feed/Exception.xmlEntity' => 'Refused to parse feed "{url}" because it contains an XXE attack',
'Exception.JKingWeb/Arsse/Feed/Exception.subscriptionNotFound' => 'Unable to find a feed at location "{url}"',
diff --git a/tests/cases/Feed/TestException.php b/tests/cases/Feed/TestException.php
new file mode 100644
index 00000000..9fc35747
--- /dev/null
+++ b/tests/cases/Feed/TestException.php
@@ -0,0 +1,177 @@
+mockGuzzleException(TransferException::class, "cURL error $code: Some message", 0);
+ $this->assertException($message, "Feed");
+ throw new FeedException("https://example.com/", $e);
+ }
+
+ public function provideCurlErrors() {
+ return [
+ 'CURLE_UNSUPPORTED_PROTOCOL' => [1, "invalidUrl"],
+ 'CURLE_FAILED_INIT' => [2, "internalError"],
+ 'CURLE_URL_MALFORMAT' => [3, "invalidUrl"],
+ 'CURLE_URL_MALFORMAT_USER' => [4, "internalError"],
+ 'CURLE_COULDNT_RESOLVE_PROXY' => [5, "transmissionError"],
+ 'CURLE_COULDNT_RESOLVE_HOST' => [6, "connectionFailed"],
+ 'CURLE_COULDNT_CONNECT' => [7, "connectionFailed"],
+ 'CURLE_WEIRD_SERVER_REPLY' => [8, "transmissionError"],
+ 'CURLE_FTP_ACCESS_DENIED' => [9, "forbidden"],
+ 'CURLE_FTP_USER_PASSWORD_INCORRECT' => [10, "unauthorized"],
+ 'CURLE_FTP_WEIRD_PASS_REPLY' => [11, "transmissionError"],
+ 'CURLE_FTP_WEIRD_USER_REPLY' => [12, "transmissionError"],
+ 'CURLE_FTP_WEIRD_PASV_REPLY' => [13, "transmissionError"],
+ 'CURLE_FTP_WEIRD_227_FORMAT' => [14, "transmissionError"],
+ 'CURLE_FTP_CANT_GET_HOST' => [15, "connectionFailed"],
+ 'CURLE_FTP_CANT_RECONNECT' => [16, "connectionFailed"],
+ 'CURLE_FTP_COULDNT_SET_BINARY' => [17, "transmissionError"],
+ 'CURLE_PARTIAL_FILE' => [18, "transmissionError"],
+ 'CURLE_FTP_COULDNT_RETR_FILE' => [19, "transmissionError"],
+ 'CURLE_FTP_WRITE_ERROR' => [20, "transmissionError"],
+ 'CURLE_FTP_QUOTE_ERROR' => [21, "transmissionError"],
+ 'CURLE_HTTP_NOT_FOUND' => [22, "invalidUrl"],
+ 'CURLE_WRITE_ERROR' => [23, "transmissionError"],
+ 'CURLE_MALFORMAT_USER' => [24, "transmissionError"],
+ 'CURLE_FTP_COULDNT_STOR_FILE' => [25, "transmissionError"],
+ 'CURLE_READ_ERROR' => [26, "transmissionError"],
+ 'CURLE_OUT_OF_MEMORY' => [27, "internalError"],
+ 'CURLE_OPERATION_TIMEDOUT' => [28, "timeout"],
+ 'CURLE_FTP_COULDNT_SET_ASCII' => [29, "transmissionError"],
+ 'CURLE_FTP_PORT_FAILED' => [30, "transmissionError"],
+ 'CURLE_FTP_COULDNT_USE_REST' => [31, "transmissionError"],
+ 'CURLE_FTP_COULDNT_GET_SIZE' => [32, "transmissionError"],
+ 'CURLE_HTTP_RANGE_ERROR' => [33, "transmissionError"],
+ 'CURLE_HTTP_POST_ERROR' => [34, "internalError"],
+ 'CURLE_SSL_CONNECT_ERROR' => [35, "invalidCertificate"],
+ 'CURLE_BAD_DOWNLOAD_RESUME' => [36, "transmissionError"],
+ 'CURLE_FILE_COULDNT_READ_FILE' => [37, "transmissionError"],
+ 'CURLE_LDAP_CANNOT_BIND' => [38, "transmissionError"],
+ 'CURLE_LDAP_SEARCH_FAILED' => [39, "transmissionError"],
+ 'CURLE_LIBRARY_NOT_FOUND' => [40, "internalError"],
+ 'CURLE_FUNCTION_NOT_FOUND' => [41, "internalError"],
+ 'CURLE_ABORTED_BY_CALLBACK' => [42, "internalError"],
+ 'CURLE_BAD_FUNCTION_ARGUMENT' => [43, "internalError"],
+ 'CURLE_BAD_CALLING_ORDER' => [44, "internalError"],
+ 'CURLE_HTTP_PORT_FAILED' => [45, "transmissionError"],
+ 'CURLE_BAD_PASSWORD_ENTERED' => [46, "unauthorized"],
+ 'CURLE_TOO_MANY_REDIRECTS' => [47, "maxRedirect"],
+ 'CURLE_UNKNOWN_TELNET_OPTION' => [48, "internalError"],
+ 'CURLE_TELNET_OPTION_SYNTAX' => [49, "internalError"],
+ 'Unknown error 50' => [50, "internalError"],
+ 'Unknown error 51' => [51, "internalError"],
+ 'CURLE_GOT_NOTHING' => [52, "transmissionError"],
+ 'CURLE_SSL_ENGINE_NOTFOUND' => [53, "invalidCertificate"],
+ 'CURLE_SSL_ENGINE_SETFAILED' => [54, "invalidCertificate"],
+ 'CURLE_SEND_ERROR' => [55, "transmissionError"],
+ 'CURLE_RECV_ERROR' => [56, "transmissionError"],
+ 'CURLE_SHARE_IN_USE' => [57, "internalError"],
+ 'CURLE_SSL_CERTPROBLEM' => [58, "invalidCertificate"],
+ 'CURLE_SSL_CIPHER' => [59, "invalidCertificate"],
+ 'CURLE_SSL_CACERT' => [60, "invalidCertificate"],
+ 'CURLE_BAD_CONTENT_ENCODING' => [61, "transmissionError"],
+ 'CURLE_LDAP_INVALID_URL' => [62, "invalidUrl"],
+ 'CURLE_FILESIZE_EXCEEDED' => [63, "transmissionError"],
+ 'CURLE_USE_SSL_FAILED' => [64, "invalidCertificate"],
+ 'CURLE_SEND_FAIL_REWIND' => [65, "transmissionError"],
+ 'CURLE_SSL_ENGINE_INITFAILED' => [66, "invalidCertificate"],
+ 'CURLE_LOGIN_DENIED' => [67, "forbidden"],
+ 'CURLE_TFTP_NOTFOUND' => [68, "invalidUrl"],
+ 'CURLE_TFTP_PERM' => [69, "forbidden"],
+ 'CURLE_REMOTE_DISK_FULL' => [70, "transmissionError"],
+ 'CURLE_TFTP_ILLEGAL' => [71, "internalError"],
+ 'CURLE_TFTP_UNKNOWNID' => [72, "internalError"],
+ 'CURLE_REMOTE_FILE_EXISTS' => [73, "transmissionError"],
+ 'CURLE_TFTP_NOSUCHUSER' => [74, "transmissionError"],
+ 'CURLE_CONV_FAILED' => [75, "internalError"],
+ 'CURLE_CONV_REQD' => [76, "internalError"],
+ 'CURLE_SSL_CACERT_BADFILE' => [77, "invalidCertificate"],
+ 'CURLE_REMOTE_FILE_NOT_FOUND' => [78, "invalidUrl"],
+ 'CURLE_SSH' => [79, "internalError"],
+ 'CURLE_SSL_PINNEDPUBKEYNOTMATCH' => [90, "invalidCertificate"],
+ 'CURLE_SSL_INVALIDCERTSTATUS' => [91, "invalidCertificate"],
+ 'CURLE_HTTP2_STREAM' => [92, "transmissionError"],
+ 'CURLE_RECURSIVE_API_CALL' => [93, "internalError"],
+ 'CURLE_AUTH_ERROR' => [94, "unauthorized"],
+ 'CURLE_HTTP3' => [95, "transmissionError"],
+ 'CURLE_QUIC_CONNECT_ERROR' => [96, "connectionFailed"],
+ 'Hypothetical error 2112' => [2112, "internalError"],
+ ];
+ }
+
+ /** @dataProvider provideHTTPErrors */
+ public function testHandleHttpErrors(int $code, string $message): void {
+ $e = $this->mockGuzzleException(BadResponseException::class, "Irrelevant message", $code);
+ $this->assertException($message, "Feed");
+ throw new FeedException("https://example.com/", $e);
+ }
+
+ public function provideHTTPErrors() {
+ $specials = [
+ 401 => "unauthorized",
+ 403 => "forbidden",
+ 404 => "invalidUrl",
+ 408 => "timeout",
+ 410 => "invalidUrl",
+ 414 => "invalidUrl",
+ 451 => "invalidUrl"
+ ];
+ $out = array_fill(400, (600 - 400), "transmissionError");
+ foreach ($specials as $k => $t) {
+ $out[$k] = $t;
+ }
+ foreach ($out as $k => $t) {
+ $out[$k] = [$k, $t];
+ }
+ return $out;
+ }
+
+ /** @dataProvider providePicoFeedException */
+ public function testHandlePicofeedException(PicoFeedException $e, string $message) {
+ $this->assertException($message, "Feed");
+ throw new FeedException("https://example.com/", $e);
+ }
+
+ public function providePicoFeedException() {
+ return [
+ 'Failed feed discovery' => [new \PicoFeed\Reader\SubscriptionNotFoundException(), "subscriptionNotFound"],
+ 'Unsupported format' => [new \PicoFeed\Reader\UnsupportedFeedFormatException(), "unsupportedFeedFormat"],
+ 'Malformed XML' => [new \PicoFeed\Parser\MalformedXmlException(), "malformedXml"],
+ 'XML entity expansion' => [new \PicoFeed\Parser\XmlEntityException(), "xmlEntity"],
+ ];
+ }
+
+ public function testHandleExcessRedirections() {
+ $e = $this->mockGuzzleException(TooManyRedirectsException::class, "Irrelevant message", 404);
+ $this->assertException("maxRedirect", "Feed");
+ throw new FeedException("https://example.com/", $e);
+ }
+
+ public function testHandleGenericStreamErrors() {
+ $e = $this->mockGuzzleException(TransferException::class, "Error creating resource: Irrelevant message", 403);
+ $this->assertException("transmissionError", "Feed");
+ throw new FeedException("https://example.com/", $e);
+ }
+
+ public function testHandleUnexpectedError() {
+ $e = new \Exception;
+ $this->assertException("internalError", "Feed");
+ throw new FeedException("https://example.com/", $e);
+ }
+}
diff --git a/tests/cases/Feed/TestFeed.php b/tests/cases/Feed/TestFeed.php
index 1bcf50e5..c419100e 100644
--- a/tests/cases/Feed/TestFeed.php
+++ b/tests/cases/Feed/TestFeed.php
@@ -14,7 +14,6 @@ use JKingWeb\Arsse\Test\Result;
/**
* @covers \JKingWeb\Arsse\Feed
- * @covers \JKingWeb\Arsse\Feed\Exception
* @group slow */
class TestFeed extends \JKingWeb\Arsse\Test\AbstractTest {
protected static $host = "http://localhost:8000/";
diff --git a/tests/phpunit.dist.xml b/tests/phpunit.dist.xml
index 6732b56a..ece5eef9 100644
--- a/tests/phpunit.dist.xml
+++ b/tests/phpunit.dist.xml
@@ -56,6 +56,7 @@
cases/User/TestUser.php
+ cases/Feed/TestException.php
cases/Feed/TestFetching.php
cases/Feed/TestFeed.php