use {
crate::request::{
DEFAULT_GEMINI_PORT, RequestOptions, Response, request_line,
trust::{CertificateStore, check_tofu, client_config, tofu_client_config},
},
std::{
io::{self, Read, Write},
net::{TcpStream, ToSocketAddrs},
sync::Arc,
time::{Duration, Instant},
},
};
struct DeadlineStream {
stream: TcpStream,
deadline: Instant,
}
impl Read for DeadlineStream {
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
let remaining_duration = remaining_time(self.deadline)?;
self.stream.set_read_timeout(Some(remaining_duration))?;
self.stream.read(buffer).map_err(normalize_timeout)
}
}
impl Write for DeadlineStream {
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
let remaining_duration = remaining_time(self.deadline)?;
self.stream.set_write_timeout(Some(remaining_duration))?;
self.stream.write(buffer).map_err(normalize_timeout)
}
fn flush(&mut self) -> io::Result<()> { self.stream.flush() }
}
fn remaining_time(deadline: Instant) -> io::Result<Duration> {
deadline
.checked_duration_since(Instant::now())
.filter(|duration| !duration.is_zero())
.ok_or_else(timeout_error)
}
fn timeout_error() -> io::Error {
io::Error::new(io::ErrorKind::TimedOut, "Gemini request timed out")
}
fn normalize_timeout(error: io::Error) -> io::Error {
if matches!(error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut)
{
timeout_error()
} else {
error
}
}
pub fn request(url: &url::Url) -> anyhow::Result<Response> {
request_with_options(url, &RequestOptions::default())
}
pub fn request_with_roots(
url: &url::Url,
roots: &rustls::RootCertStore,
) -> anyhow::Result<Response> {
let options = RequestOptions::with_roots(roots.clone());
request_with_options(url, &options)
}
pub fn request_with_options(
url: &url::Url,
options: &RequestOptions,
) -> anyhow::Result<Response> {
request_with_config(
url,
options,
client_config(options.root_certificates.clone()),
|_, _, _| Ok(()),
)
}
pub fn request_with_tofu(
url: &url::Url,
store: &mut dyn CertificateStore,
options: &RequestOptions,
) -> anyhow::Result<Response> {
request_with_config(
url,
options,
tofu_client_config(),
|certificate, host, port| check_tofu(store, host, port, certificate),
)
}
fn request_with_config(
url: &url::Url,
options: &RequestOptions,
config: rustls::ClientConfig,
check_certificate: impl FnOnce(
Option<&rustls::Certificate>,
&str,
u16,
) -> anyhow::Result<()>,
) -> anyhow::Result<Response> {
let request_line = request_line(url)?;
let domain = url
.domain()
.ok_or_else(|| anyhow::anyhow!("Invalid URL: missing domain"))?;
let port = url.port().unwrap_or(DEFAULT_GEMINI_PORT);
let deadline = Instant::now()
.checked_add(options.timeout)
.ok_or_else(|| anyhow::anyhow!("Gemini request timeout is too large"))?;
let mut connection_error =
io::Error::new(io::ErrorKind::AddrNotAvailable, "No server address found");
let mut connected_stream = None;
for address in (domain, port).to_socket_addrs()? {
match TcpStream::connect_timeout(&address, remaining_time(deadline)?) {
Ok(stream) => {
connected_stream = Some(stream);
break;
}
Err(error) => connection_error = error,
}
}
let mut stream = DeadlineStream {
stream: connected_stream.ok_or(connection_error)?,
deadline,
};
let mut connection =
rustls::ClientConnection::new(Arc::new(config), domain.try_into()?)?;
connection.complete_io(&mut stream)?;
check_certificate(
connection.peer_certificates().and_then(|chain| chain.first()),
domain,
port,
)?;
let mut tls = rustls::Stream::new(&mut connection, &mut stream);
tls.write_all(request_line.as_bytes())?;
let mut response_bytes = Vec::new();
let read_limit_bytes = options.max_response_bytes.saturating_add(1) as u64;
Read::take(&mut tls, read_limit_bytes).read_to_end(&mut response_bytes)?;
anyhow::ensure!(
response_bytes.len() <= options.max_response_bytes,
"Gemini response exceeds {} bytes",
options.max_response_bytes
);
Response::parse(&response_bytes, tls.conn.negotiated_cipher_suite())
}