lychee_lib/types/
error.rs

1use http::StatusCode;
2use serde::{Serialize, Serializer};
3use std::error::Error;
4use std::hash::Hash;
5use std::{convert::Infallible, path::PathBuf};
6use thiserror::Error;
7use tokio::task::JoinError;
8
9use super::InputContent;
10use crate::types::StatusCodeSelectorError;
11use crate::{Uri, basic_auth::BasicAuthExtractorError, utils};
12
13/// Kinds of status errors
14/// Note: The error messages can change over time, so don't match on the output
15#[derive(Error, Debug)]
16#[non_exhaustive]
17pub enum ErrorKind {
18    /// Network error while handling request.
19    /// This does not include erroneous status codes, `RejectedStatusCode` will be used in that case.
20    #[error("Network error")]
21    NetworkRequest(#[source] reqwest::Error),
22    /// Cannot read the body of the received response
23    #[error("Error reading response body: {0}")]
24    ReadResponseBody(#[source] reqwest::Error),
25    /// The network client required for making requests cannot be created
26    #[error("Error creating request client: {0}")]
27    BuildRequestClient(#[source] reqwest::Error),
28
29    /// Network error while using GitHub API
30    #[error("Network error (GitHub client)")]
31    GithubRequest(#[from] Box<octocrab::Error>),
32
33    /// Error while executing a future on the Tokio runtime
34    #[error("Task failed to execute to completion")]
35    RuntimeJoin(#[from] JoinError),
36
37    /// Error while converting a file to an input
38    #[error("Cannot read input content from file `{1}`")]
39    ReadFileInput(#[source] std::io::Error, PathBuf),
40
41    /// Error while reading stdin as input
42    #[error("Cannot read input content from stdin")]
43    ReadStdinInput(#[from] std::io::Error),
44
45    /// Errors which can occur when attempting to interpret a sequence of u8 as a string
46    #[error("Attempted to interpret an invalid sequence of bytes as a string")]
47    Utf8(#[from] std::str::Utf8Error),
48
49    /// The GitHub client required for making requests cannot be created
50    #[error("Error creating GitHub client")]
51    BuildGithubClient(#[source] Box<octocrab::Error>),
52
53    /// Invalid GitHub URL
54    #[error("GitHub URL is invalid: {0}")]
55    InvalidGithubUrl(String),
56
57    /// The input is empty and not accepted as a valid URL
58    #[error("URL cannot be empty")]
59    EmptyUrl,
60
61    /// The given string can not be parsed into a valid URL, e-mail address, or file path
62    #[error("Cannot parse string `{1}` as website url: {0}")]
63    ParseUrl(#[source] url::ParseError, String),
64
65    /// The given URI cannot be converted to a file path
66    #[error("Cannot find file")]
67    InvalidFilePath(Uri),
68
69    /// The given URI's fragment could not be found within the page content
70    #[error("Cannot find fragment")]
71    InvalidFragment(Uri),
72
73    /// The given directory is missing a required index file
74    #[error("Cannot find index file within directory")]
75    InvalidIndexFile(PathBuf),
76
77    /// The given path cannot be converted to a URI
78    #[error("Invalid path to URL conversion: {0}")]
79    InvalidUrlFromPath(PathBuf),
80
81    /// The given mail address is unreachable
82    #[error("Unreachable mail address: {0}: {1}")]
83    UnreachableEmailAddress(Uri, String),
84
85    /// The given header could not be parsed.
86    /// A possible error when converting a `HeaderValue` from a string or byte
87    /// slice.
88    #[error("Header could not be parsed.")]
89    InvalidHeader(#[from] http::header::InvalidHeaderValue),
90
91    /// The given string can not be parsed into a valid base URL or base directory
92    #[error("Error with base dir `{0}` : {1}")]
93    InvalidBase(String, String),
94
95    /// Cannot join the given text with the base URL
96    #[error("Cannot join '{0}' with the base URL")]
97    InvalidBaseJoin(String),
98
99    /// Cannot convert the given path to a URI
100    #[error("Cannot convert path '{0}' to a URI")]
101    InvalidPathToUri(String),
102
103    /// Root dir must be an absolute path
104    #[error("Root dir must be an absolute path: '{0}'")]
105    RootDirMustBeAbsolute(PathBuf),
106
107    /// The given URI type is not supported
108    #[error("Unsupported URI type: '{0}'")]
109    UnsupportedUriType(String),
110
111    /// The given input can not be parsed into a valid URI remapping
112    #[error("Error remapping URL: `{0}`")]
113    InvalidUrlRemap(String),
114
115    /// The given path does not resolve to a valid file
116    #[error("Invalid file path: {0}")]
117    InvalidFile(PathBuf),
118
119    /// Error while traversing an input directory
120    #[error("Cannot traverse input directory: {0}")]
121    DirTraversal(#[from] ignore::Error),
122
123    /// The given glob pattern is not valid
124    #[error("UNIX glob pattern is invalid")]
125    InvalidGlobPattern(#[from] glob::PatternError),
126
127    /// The GitHub API could not be called because of a missing GitHub token.
128    #[error(
129        "GitHub token not specified. To check GitHub links reliably, use `--github-token` flag / `GITHUB_TOKEN` env var."
130    )]
131    MissingGitHubToken,
132
133    /// Used an insecure URI where a secure variant was reachable
134    #[error("This URI is available in HTTPS protocol, but HTTP is provided. Use '{0}' instead")]
135    InsecureURL(Uri),
136
137    /// Error while sending/receiving messages from MPSC channel
138    #[error("Cannot send/receive message from channel")]
139    Channel(#[from] tokio::sync::mpsc::error::SendError<InputContent>),
140
141    /// An URL with an invalid host was found
142    #[error("URL is missing a host")]
143    InvalidUrlHost,
144
145    /// Cannot parse the given URI
146    #[error("The given URI is invalid: {0}")]
147    InvalidURI(Uri),
148
149    /// The given status code is invalid (not in the range 100-1000)
150    #[error("Invalid status code: {0}")]
151    InvalidStatusCode(u16),
152
153    /// The given status code was not accepted (this depends on the `accept` configuration)
154    #[error(r#"Rejected status code (this depends on your "accept" configuration)"#)]
155    RejectedStatusCode(StatusCode),
156
157    /// Regex error
158    #[error("Error when using regex engine: {0}")]
159    Regex(#[from] regex::Error),
160
161    /// Too many redirects (HTTP 3xx) were encountered (configurable)
162    #[error("Too many redirects")]
163    TooManyRedirects(#[source] reqwest::Error),
164
165    /// Basic auth extractor error
166    #[error("Basic auth extractor error")]
167    BasicAuthExtractorError(#[from] BasicAuthExtractorError),
168
169    /// Cannot load cookies
170    #[error("Cannot load cookies")]
171    Cookies(String),
172
173    /// Status code selector parse error
174    #[error("Status code range error")]
175    StatusCodeSelectorError(#[from] StatusCodeSelectorError),
176}
177
178impl ErrorKind {
179    /// Return more details about the given [`ErrorKind`]
180    ///
181    /// Which additional information we can extract depends on the underlying
182    /// request type. The output is purely meant for humans (e.g. for status
183    /// messages) and future changes are expected.
184    #[must_use]
185    pub fn details(&self) -> Option<String> {
186        match self {
187            ErrorKind::NetworkRequest(e) => {
188                // Get the relevant details from the specific reqwest error
189                let details = utils::reqwest::trim_error_output(e);
190
191                // Provide support for common error types
192                if e.is_connect() {
193                    Some(format!("{details} Maybe a certificate error?"))
194                } else {
195                    Some(details)
196                }
197            }
198            ErrorKind::RejectedStatusCode(status) => Some(
199                status
200                    .canonical_reason()
201                    .unwrap_or("Unknown status code")
202                    .to_string(),
203            ),
204            ErrorKind::GithubRequest(e) => {
205                if let octocrab::Error::GitHub { source, .. } = &**e {
206                    Some(source.message.clone())
207                } else {
208                    None
209                }
210            }
211            _ => self.source().map(ToString::to_string),
212        }
213    }
214
215    /// Return the underlying source of the given [`ErrorKind`]
216    /// if it is a `reqwest::Error`.
217    /// This is useful for extracting the status code of a failed request.
218    /// If the error is not a `reqwest::Error`, `None` is returned.
219    #[must_use]
220    #[allow(clippy::redundant_closure_for_method_calls)]
221    pub(crate) fn reqwest_error(&self) -> Option<&reqwest::Error> {
222        self.source()
223            .and_then(|e| e.downcast_ref::<reqwest::Error>())
224    }
225
226    /// Return the underlying source of the given [`ErrorKind`]
227    /// if it is a `octocrab::Error`.
228    /// This is useful for extracting the status code of a failed request.
229    /// If the error is not a `octocrab::Error`, `None` is returned.
230    #[must_use]
231    #[allow(clippy::redundant_closure_for_method_calls)]
232    pub(crate) fn github_error(&self) -> Option<&octocrab::Error> {
233        self.source()
234            .and_then(|e| e.downcast_ref::<octocrab::Error>())
235    }
236}
237
238#[allow(clippy::match_same_arms)]
239impl PartialEq for ErrorKind {
240    fn eq(&self, other: &Self) -> bool {
241        match (self, other) {
242            (Self::NetworkRequest(e1), Self::NetworkRequest(e2)) => {
243                e1.to_string() == e2.to_string()
244            }
245            (Self::ReadResponseBody(e1), Self::ReadResponseBody(e2)) => {
246                e1.to_string() == e2.to_string()
247            }
248            (Self::BuildRequestClient(e1), Self::BuildRequestClient(e2)) => {
249                e1.to_string() == e2.to_string()
250            }
251            (Self::RuntimeJoin(e1), Self::RuntimeJoin(e2)) => e1.to_string() == e2.to_string(),
252            (Self::ReadFileInput(e1, s1), Self::ReadFileInput(e2, s2)) => {
253                e1.kind() == e2.kind() && s1 == s2
254            }
255            (Self::ReadStdinInput(e1), Self::ReadStdinInput(e2)) => e1.kind() == e2.kind(),
256            (Self::GithubRequest(e1), Self::GithubRequest(e2)) => e1.to_string() == e2.to_string(),
257            (Self::InvalidGithubUrl(s1), Self::InvalidGithubUrl(s2)) => s1 == s2,
258            (Self::ParseUrl(s1, e1), Self::ParseUrl(s2, e2)) => s1 == s2 && e1 == e2,
259            (Self::UnreachableEmailAddress(u1, ..), Self::UnreachableEmailAddress(u2, ..)) => {
260                u1 == u2
261            }
262            (Self::InsecureURL(u1), Self::InsecureURL(u2)) => u1 == u2,
263            (Self::InvalidGlobPattern(e1), Self::InvalidGlobPattern(e2)) => {
264                e1.msg == e2.msg && e1.pos == e2.pos
265            }
266            (Self::InvalidHeader(_), Self::InvalidHeader(_))
267            | (Self::MissingGitHubToken, Self::MissingGitHubToken) => true,
268            (Self::InvalidStatusCode(c1), Self::InvalidStatusCode(c2)) => c1 == c2,
269            (Self::InvalidUrlHost, Self::InvalidUrlHost) => true,
270            (Self::InvalidURI(u1), Self::InvalidURI(u2)) => u1 == u2,
271            (Self::Regex(e1), Self::Regex(e2)) => e1.to_string() == e2.to_string(),
272            (Self::DirTraversal(e1), Self::DirTraversal(e2)) => e1.to_string() == e2.to_string(),
273            (Self::Channel(_), Self::Channel(_)) => true,
274            (Self::TooManyRedirects(e1), Self::TooManyRedirects(e2)) => {
275                e1.to_string() == e2.to_string()
276            }
277            (Self::BasicAuthExtractorError(e1), Self::BasicAuthExtractorError(e2)) => {
278                e1.to_string() == e2.to_string()
279            }
280            (Self::Cookies(e1), Self::Cookies(e2)) => e1 == e2,
281            (Self::InvalidFile(p1), Self::InvalidFile(p2)) => p1 == p2,
282            (Self::InvalidFilePath(u1), Self::InvalidFilePath(u2)) => u1 == u2,
283            (Self::InvalidFragment(u1), Self::InvalidFragment(u2)) => u1 == u2,
284            (Self::InvalidIndexFile(p1), Self::InvalidIndexFile(p2)) => p1 == p2,
285            (Self::InvalidUrlFromPath(p1), Self::InvalidUrlFromPath(p2)) => p1 == p2,
286            (Self::InvalidBase(b1, e1), Self::InvalidBase(b2, e2)) => b1 == b2 && e1 == e2,
287            (Self::InvalidUrlRemap(r1), Self::InvalidUrlRemap(r2)) => r1 == r2,
288            (Self::EmptyUrl, Self::EmptyUrl) => true,
289
290            _ => false,
291        }
292    }
293}
294
295impl Eq for ErrorKind {}
296
297#[allow(clippy::match_same_arms)]
298impl Hash for ErrorKind {
299    fn hash<H>(&self, state: &mut H)
300    where
301        H: std::hash::Hasher,
302    {
303        match self {
304            Self::RuntimeJoin(e) => e.to_string().hash(state),
305            Self::ReadFileInput(e, s) => (e.kind(), s).hash(state),
306            Self::ReadStdinInput(e) => e.kind().hash(state),
307            Self::NetworkRequest(e) => e.to_string().hash(state),
308            Self::ReadResponseBody(e) => e.to_string().hash(state),
309            Self::BuildRequestClient(e) => e.to_string().hash(state),
310            Self::BuildGithubClient(e) => e.to_string().hash(state),
311            Self::GithubRequest(e) => e.to_string().hash(state),
312            Self::InvalidGithubUrl(s) => s.hash(state),
313            Self::DirTraversal(e) => e.to_string().hash(state),
314            Self::InvalidFile(e) => e.to_string_lossy().hash(state),
315            Self::EmptyUrl => "Empty URL".hash(state),
316            Self::ParseUrl(e, s) => (e.to_string(), s).hash(state),
317            Self::InvalidURI(u) => u.hash(state),
318            Self::InvalidUrlFromPath(p) => p.hash(state),
319            Self::Utf8(e) => e.to_string().hash(state),
320            Self::InvalidFilePath(u) => u.hash(state),
321            Self::InvalidFragment(u) => u.hash(state),
322            Self::InvalidIndexFile(p) => p.hash(state),
323            Self::UnreachableEmailAddress(u, ..) => u.hash(state),
324            Self::InsecureURL(u, ..) => u.hash(state),
325            Self::InvalidBase(base, e) => (base, e).hash(state),
326            Self::InvalidBaseJoin(s) => s.hash(state),
327            Self::InvalidPathToUri(s) => s.hash(state),
328            Self::RootDirMustBeAbsolute(s) => s.hash(state),
329            Self::UnsupportedUriType(s) => s.hash(state),
330            Self::InvalidUrlRemap(remap) => (remap).hash(state),
331            Self::InvalidHeader(e) => e.to_string().hash(state),
332            Self::InvalidGlobPattern(e) => e.to_string().hash(state),
333            Self::InvalidStatusCode(c) => c.hash(state),
334            Self::RejectedStatusCode(c) => c.hash(state),
335            Self::Channel(e) => e.to_string().hash(state),
336            Self::MissingGitHubToken | Self::InvalidUrlHost => {
337                std::mem::discriminant(self).hash(state);
338            }
339            Self::Regex(e) => e.to_string().hash(state),
340            Self::TooManyRedirects(e) => e.to_string().hash(state),
341            Self::BasicAuthExtractorError(e) => e.to_string().hash(state),
342            Self::Cookies(e) => e.to_string().hash(state),
343            Self::StatusCodeSelectorError(e) => e.to_string().hash(state),
344        }
345    }
346}
347
348impl Serialize for ErrorKind {
349    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
350    where
351        S: Serializer,
352    {
353        serializer.collect_str(self)
354    }
355}
356
357impl From<Infallible> for ErrorKind {
358    fn from(_: Infallible) -> Self {
359        // tautological
360        unreachable!()
361    }
362}