use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use url::Url;
use crate::gemini::parse as parse_gemini;
use crate::tls::client_connector;
use crate::{Error, Response};
pub const MISFIN_PORT: u16 = 1958;
pub struct ClientIdentity {
pub cert_chain: Vec<CertificateDer<'static>>,
pub private_key: PrivateKeyDer<'static>,
}
fn request_parts(recipient: &Url, message: &str) -> Result<(String, u16, String), Error> {
if recipient.scheme() != "misfin" {
return Err(Error::UnsupportedScheme(recipient.scheme().to_string()));
}
let host = recipient
.host_str()
.ok_or_else(|| Error::BadUrl("misfin URL has no host".into()))?;
let mailbox = recipient.username();
if mailbox.is_empty() {
return Err(Error::BadUrl(
"misfin URL has no mailbox (expected misfin://mailbox@host)".into(),
));
}
let port = recipient.port().unwrap_or(MISFIN_PORT);
let request = format!("misfin://{mailbox}@{host} {message}\r\n");
Ok((host.to_string(), port, request))
}
pub async fn send(
recipient: &Url,
message: &str,
identity: ClientIdentity,
) -> Result<Response, Error> {
let (host, port, request) = request_parts(recipient, message)?;
let tcp = TcpStream::connect((host.as_str(), port))
.await
.map_err(|e| Error::Connect(format!("tcp {host}:{port}: {e}")))?;
let server_name = ServerName::try_from(host.clone())
.map_err(|e| Error::Connect(format!("server name {host}: {e}")))?;
let connector = client_connector(identity.cert_chain, identity.private_key)
.map_err(|e| Error::Connect(format!("client tls config: {e}")))?;
let mut tls = connector
.connect(server_name, tcp)
.await
.map_err(|e| Error::Connect(format!("tls handshake: {e}")))?;
tls.write_all(request.as_bytes())
.await
.map_err(|e| Error::Io(e.to_string()))?;
let mut raw = Vec::new();
tls.read_to_end(&mut raw)
.await
.map_err(|e| Error::Io(e.to_string()))?;
parse_gemini(recipient, &raw)
}
#[cfg(test)]
mod tests {
use super::*;
fn url(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn builds_the_request_line_and_dial_target() {
let (host, port, request) =
request_parts(&url("misfin://alice@example.test"), "Hi Bob").unwrap();
assert_eq!(host, "example.test");
assert_eq!(port, MISFIN_PORT);
assert_eq!(request, "misfin://alice@example.test Hi Bob\r\n");
}
#[test]
fn an_explicit_port_is_the_dial_target_only() {
let (host, port, request) =
request_parts(&url("misfin://alice@example.test:2000"), "hi").unwrap();
assert_eq!((host.as_str(), port), ("example.test", 2000));
assert_eq!(request, "misfin://alice@example.test hi\r\n", "addr-spec omits the port");
}
#[test]
fn rejects_a_non_misfin_scheme() {
assert!(matches!(
request_parts(&url("gemini://example.test/"), "x"),
Err(Error::UnsupportedScheme(_))
));
}
#[test]
fn rejects_a_missing_mailbox() {
assert!(matches!(
request_parts(&url("misfin://example.test"), "x"),
Err(Error::BadUrl(_))
));
}
}