tokio_gemini/client/
mod.rs

1//! Everything related to Gemini client except cert verification
2
3pub mod builder;
4pub mod response;
5
6#[cfg(test)]
7pub mod tests;
8
9pub use response::Response;
10
11#[cfg(feature = "hickory")]
12use crate::dns::DnsClient;
13#[cfg(feature = "hickory")]
14use hickory_client::rr::IntoName;
15#[cfg(feature = "hickory")]
16use std::net::SocketAddr;
17
18use crate::{
19    certs::{SelfsignedCertVerifier, ServerName},
20    error::*,
21    into_url::IntoUrl,
22    status::*,
23};
24use builder::ClientBuilder;
25
26use std::sync::Arc;
27
28use tokio::{
29    io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader},
30    net::TcpStream,
31};
32use tokio_rustls::{client::TlsStream, rustls, TlsConnector};
33
34pub type ThisResponse = Response<BufReader<TlsStream<TcpStream>>>;
35
36pub struct Client {
37    pub(crate) connector: TlsConnector,
38    pub(crate) ss_verifier: Option<Arc<dyn SelfsignedCertVerifier>>,
39    #[cfg(feature = "hickory")]
40    pub(crate) dns: Option<DnsClient>,
41}
42
43impl Client {
44    /// Construct a Client with a customized configuration,
45    /// see [`ClientBuilder`] methods.
46    pub fn builder() -> ClientBuilder {
47        ClientBuilder::new()
48    }
49}
50
51impl Client {
52    /// Perform a Gemini request with the specified URL.
53    /// Host and port (1965 by default) are parsed from `url`
54    /// after scheme and userinfo checks.
55    /// On success, [`Response`] is returned.
56    ///
57    /// Automatically follows redirections up to 5 times.
58    /// To avoid this behavior, use [`Client::request_with_no_redirect`],
59    /// this function is also called here under the hood.
60    ///
61    /// Returns an error if a scheme is not `gemini://` or
62    /// a userinfo portion (`user:password@`) is present in the URL.
63    /// To avoid this checks, most probably for proxying requests,
64    /// use [`Client::request_with_host`].
65    ///
66    /// # Errors
67    /// - See [`Client::request_with_no_redirect`].
68    pub async fn request(&self, url: impl IntoUrl) -> Result<ThisResponse, LibError> {
69        // first request
70        let mut resp = self.request_with_no_redirect(url).await?;
71
72        let mut i: u8 = 0;
73        const MAX: u8 = 5;
74
75        // repeat requests until we get a non-30 status
76        // or hit the redirection depth limit
77        loop {
78            if resp.status().reply_type() == ReplyType::Redirect && i < MAX {
79                resp = self.request_with_no_redirect(resp.message()).await?;
80                i += 1;
81                continue;
82            }
83            return Ok(resp);
84        }
85    }
86
87    /// Perform a Gemini request with the specified URL
88    /// **without** following redirections.
89    /// Host and port (1965 by default) are parsed from `url`
90    /// after scheme and userinfo checks.
91    /// On success, [`Response`] is returned.
92    ///
93    /// # Errors
94    /// - [`InvalidUrl::ParseError`] means that the given URL cannot be parsed.
95    /// - [`InvalidUrl::SchemeNotGemini`] is returned when a scheme is not `gemini://`,
96    ///   for proxying requests use [`Client::request_with_host`].
97    /// - [`InvalidUrl::UserinfoPresent`] is returned when the given URL contains
98    ///   a userinfo portion (`user:password@`) -- it is forbidden by the Gemini specification.
99    /// - See [`Client::request_with_host`] for the rest.
100    pub async fn request_with_no_redirect(
101        &self,
102        url: impl IntoUrl,
103    ) -> Result<ThisResponse, LibError> {
104        let url = url.into_url()?;
105
106        let host = url.host_str().ok_or(InvalidUrl::ConvertError)?;
107        let port = url.port().unwrap_or(1965);
108
109        self.request_with_host(url.as_str(), host, port).await
110    }
111
112    /// Perform a Gemini request with the specified host, port and URL.
113    /// Non-`gemini://` URLs is OK if the remote server supports proxying.
114    ///
115    /// # Errors
116    /// - [`InvalidUrl::ConvertError`] means that a hostname cannot be
117    ///   converted into [`pki_types::ServerName`].
118    /// - [`LibError::HostLookupError`] means that a DNS server returned no records,
119    ///   i.&nbsp;e. that domain does not exist.
120    /// - [`LibError::DnsClientError`] (crate feature `hickory`)
121    ///   wraps a Hickory DNS client error related to a connection failure
122    ///   or an invalid DNS server response.
123    /// - [`std::io::Error`] is returned in many cases:
124    ///   could not open a TCP connection, perform a TLS handshake,
125    ///   write to or read from the TCP stream.
126    ///   Check the ErrorKind and/or the inner error
127    ///   if you need to determine what exactly happened.
128    /// - [`LibError::StatusOutOfRange`] means that a Gemini server returned
129    ///   an invalid status code (less than 10 or greater than 69).
130    /// - [`LibError::DataNotUtf8`] is returned when metadata (the text after a status code)
131    ///   is not in UTF-8 and cannot be converted to a string without errors.
132    pub async fn request_with_host(
133        &self,
134        url_str: &str,
135        host: &str,
136        port: u16,
137    ) -> Result<ThisResponse, LibError> {
138        let domain = ServerName::try_from(host)
139            .map_err(|_| InvalidUrl::ConvertError)?
140            .to_owned();
141
142        // TCP connection
143        let stream = self.try_connect(host, port).await?;
144        // TLS connection via tokio-rustls
145        let stream = self.connector.connect(domain, stream).await?;
146
147        // certificate verification
148        if let Some(ssv) = &self.ss_verifier {
149            let cert = stream
150                .get_ref()
151                .1 // rustls::ClientConnection
152                .peer_certificates()
153                .unwrap() // i think handshake already completed if we awaited on connector.connect?
154                .first()
155                .ok_or(rustls::Error::NoCertificatesPresented)?;
156
157            if !ssv.verify(cert, host, port).await? {
158                return Err(rustls::CertificateError::ApplicationVerificationFailure.into());
159            }
160        }
161
162        self.perform_io(url_str, stream).await
163    }
164
165    pub(crate) async fn perform_io<IO: AsyncReadExt + AsyncWriteExt + Unpin>(
166        &self,
167        url_str: &str,
168        mut stream: IO,
169    ) -> Result<Response<BufReader<IO>>, LibError> {
170        // Write URL, then CRLF
171        stream.write_all(url_str.as_bytes()).await?;
172        stream.write_all(b"\r\n").await?;
173        stream.flush().await?;
174
175        let status = {
176            let mut buf: [u8; 3] = [0, 0, 0]; // 2 digits, space
177            stream.read_exact(&mut buf).await?;
178            Status::parse_status(&buf)?
179        };
180
181        let mut stream = BufReader::new(stream);
182
183        let message = {
184            let mut result: Vec<u8> = Vec::new();
185            let mut buf = [0u8]; // buffer for LF (\n)
186
187            // reading message after status code
188            // until CRLF (\r\n)
189            loop {
190                // until CR
191                stream.read_until(b'\r', &mut result).await?;
192                // now read next char...
193                stream.read_exact(&mut buf).await?;
194                if buf[0] == b'\n' {
195                    // ...and check if it's LF
196                    break;
197                } else {
198                    // ...otherwise, CR is a part of message, not a CRLF terminator,
199                    // so append that one byte that's supposed to be LF (but not LF)
200                    // to the message buffer
201                    result.push(buf[0]);
202                }
203            }
204
205            // trim last CR
206            if result.last().is_some_and(|c| c == &b'\r') {
207                result.pop();
208            }
209
210            // Vec<u8> -> ASCII or UTF-8 String
211            String::from_utf8(result)?
212        };
213
214        Ok(Response::new(status, message, stream))
215    }
216
217    async fn try_connect(&self, host: &str, port: u16) -> Result<TcpStream, LibError> {
218        let mut last_err: Option<std::io::Error> = None;
219
220        #[cfg(feature = "hickory")]
221        if let Some(dns) = &self.dns {
222            let mut dns = dns.clone();
223            let name = host.into_name()?;
224
225            for ip_addr in dns.query_ipv4(name.clone()).await? {
226                match TcpStream::connect(SocketAddr::new(ip_addr, port)).await {
227                    Ok(stream) => {
228                        return Ok(stream);
229                    }
230                    Err(err) => {
231                        last_err = Some(err);
232                    }
233                }
234            }
235
236            for ip_addr in dns.query_ipv6(name).await? {
237                match TcpStream::connect(SocketAddr::new(ip_addr, port)).await {
238                    Ok(stream) => {
239                        return Ok(stream);
240                    }
241                    Err(err) => {
242                        last_err = Some(err);
243                    }
244                }
245            }
246
247            if let Some(err) = last_err {
248                return Err(err.into());
249            }
250
251            return Err(LibError::HostLookupError);
252        }
253
254        for addr in tokio::net::lookup_host((host, port)).await? {
255            match TcpStream::connect(addr).await {
256                Ok(stream) => {
257                    return Ok(stream);
258                }
259                Err(err) => {
260                    last_err = Some(err);
261                }
262            }
263        }
264
265        if let Some(err) = last_err {
266            return Err(err.into());
267        }
268
269        Err(LibError::HostLookupError)
270    }
271}