errand 0.1.1

Async small-web (smolweb) transport: gemini, gopher, finger, spartan, nex, guppy, and titan in one scheme-routed fetch, plus titan-upload and misfin-send write companions. Bytes in, bytes out, no HTTP, host-agnostic.
Documentation
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */

//! Misfin (`misfin://`, port 1958, <https://misfin.org>): gemini-style mail
//! **send**.
//!
//! Misfin is gemini-flavoured peer-to-peer mail. To deliver a message the client
//! opens a TLS connection to the recipient's host **presenting a client
//! certificate** (the sender's identity *is* its certificate), writes a request
//! line of the form
//!
//!   `misfin://<mailbox>@<host> <message>\r\n`
//!
//! and reads a gemini-format `<status> <meta>\r\n` response (2x delivered, 3x
//! redirect, 4x/5x failure, 6x certificate). It is the write companion for mail,
//! as [`titan`](crate::titan_upload) is for gemini.
//!
//! errand owns only this **client** side. The certificate is supplied by the
//! caller ([`ClientIdentity`]); errand never generates or stores certs — that is
//! the sender's identity layer. **Receiving** misfin (serving a mailbox) is a
//! server, outside errand's client-transport scope.

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};

/// Misfin's well-known port.
pub const MISFIN_PORT: u16 = 1958;

/// A client identity for a misfin send: the certificate chain + private key, in
/// DER, supplied by the caller. errand presents these in the TLS handshake; it
/// does not generate, store, or rotate them (that is the sender's identity layer,
/// e.g. a persona vault).
pub struct ClientIdentity {
    /// The client certificate chain (leaf first), DER-encoded.
    pub cert_chain: Vec<CertificateDer<'static>>,
    /// The client private key, DER-encoded.
    pub private_key: PrivateKeyDer<'static>,
}

/// The connect target + request line for a misfin send: `(host, port, request)`.
/// Split out from [`send`] so the request construction is testable without a
/// network. Errors on a non-misfin URL or one missing the `mailbox@host` form.
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);
    // The addr-spec is `mailbox@host` (no port); the port is only the dial target.
    let request = format!("misfin://{mailbox}@{host} {message}\r\n");
    Ok((host.to_string(), port, request))
}

/// Send `message` to a misfin `recipient` (`misfin://mailbox@host[:port]`),
/// presenting `identity` as the client certificate. Returns the recipient host's
/// gemini-format [`Response`] (2x delivered, 3x redirect, 4x/5x failure, 6x
/// certificate). errand does not follow misfin redirects; the caller decides.
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}")))?;

    // Send the request line, then read to EOF (the server closes when done).
    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()))?;

    // Misfin responses share gemini's `<status> <meta>\r\n` header grammar.
    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);
        // The addr-spec drops the port; the message follows a single space.
        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(_))
        ));
    }
}