errand 0.1.0

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/. */

//! The Guppy protocol (`guppy://`, UDP port 6775, <https://guppy.mozz.us>).
//!
//! Guppy is a stop-and-wait UDP protocol. The client sends the URL as a
//! single datagram; the server replies with a sequence of numbered packets
//! that the client ACKs one at a time.
//!
//! Packet envelope (server → client): `<seq>\r\n<content>`
//! - Seq 0: response header — content is `<code> <meta>` (gemini-style).
//! - Seq 1+: body chunk — content is raw bytes.
//! - Any seq with empty content: EOF signal.
//!
//! ACK (client → server): `<seq>\r\n` for each received packet.
//!
//! Status codes mirror gemini (2x success, 3x redirect, 4x/5x failure,
//! 1x input). The reassembled body is gemtext-shaped (the `nematic.guppy`
//! engine delegates to its gemtext parser).

use tokio::net::UdpSocket;
use url::Url;

use crate::{Error, Response, Scheme, Status};

/// Maximum single UDP datagram size we accept (safe below typical 1500-byte
/// Ethernet MTU with IP/UDP overhead).
const MAX_DATAGRAM: usize = 1400;

/// Per-packet receive timeout in milliseconds. If the server does not reply
/// within this window we give up; Guppy has no retransmission in this
/// basic implementation.
const RECV_TIMEOUT_MS: u64 = 5_000;

/// Fetch a `guppy://` URL over UDP.
pub(crate) async fn fetch(url: &Url) -> Result<Response, Error> {
    let host = url
        .host_str()
        .ok_or_else(|| Error::BadUrl("guppy URL has no host".into()))?;
    let port = url.port().unwrap_or_else(|| Scheme::Guppy.default_port());

    // Bind an ephemeral local UDP socket and connect it to the server so
    // subsequent send/recv calls are addressed automatically.
    let sock = UdpSocket::bind("0.0.0.0:0")
        .await
        .map_err(|e| Error::Connect(format!("udp bind: {e}")))?;
    sock.connect((host, port))
        .await
        .map_err(|e| Error::Connect(format!("udp connect {host}:{port}: {e}")))?;

    // Send the request datagram: `<url>\r\n`.
    let request = format!("{url}\r\n");
    sock.send(request.as_bytes())
        .await
        .map_err(|e| Error::Io(format!("send request: {e}")))?;

    // Receive the header packet (seq 0).
    let mut buf = vec![0u8; MAX_DATAGRAM];
    let (seq, content) = recv_packet(&sock, &mut buf).await?;
    if seq != 0 {
        return Err(Error::Protocol(format!(
            "expected seq 0 for header, got seq {seq}"
        )));
    }
    // ACK seq 0.
    send_ack(&sock, 0).await?;

    // Parse the header content as a gemini-style `<code> <meta>` line.
    let header = std::str::from_utf8(content)
        .map_err(|_| Error::Protocol("guppy header is not UTF-8".into()))?
        .trim_end_matches(['\r', '\n']);
    let (status, raw_status, meta) = parse_header(header)?;

    if status != Status::Success {
        return Ok(Response { url: url.clone(), status, raw_status, meta, body: Vec::new() });
    }

    // Receive body chunks (seq 1, 2, ...) until an empty-content packet.
    let mut body = Vec::new();
    loop {
        let (seq, content) = recv_packet(&sock, &mut buf).await?;
        send_ack(&sock, seq).await?;
        if content.is_empty() {
            break;
        }
        body.extend_from_slice(content);
    }

    Ok(Response { url: url.clone(), status: Status::Success, raw_status, meta, body })
}

/// Receive one packet and split it into `(seq_num, content_bytes)`.
/// Each packet has the envelope `<seq>\r\n<content>`.
async fn recv_packet<'b>(
    sock: &UdpSocket,
    buf: &'b mut Vec<u8>,
) -> Result<(u32, &'b [u8]), Error> {
    let n = tokio::time::timeout(
        std::time::Duration::from_millis(RECV_TIMEOUT_MS),
        sock.recv(buf),
    )
    .await
    .map_err(|_| Error::Io("guppy receive timed out".into()))?
    .map_err(|e| Error::Io(format!("udp recv: {e}")))?;

    let data = &buf[..n];
    // Split at the first \r\n: everything before is the seq number, everything
    // after is the content bytes.
    let split = data
        .windows(2)
        .position(|w| w == b"\r\n")
        .ok_or_else(|| Error::Protocol("guppy packet has no CRLF separator".into()))?;
    let seq_bytes = &data[..split];
    let content = &data[split + 2..];

    let seq_str = std::str::from_utf8(seq_bytes)
        .map_err(|_| Error::Protocol("guppy seq is not ASCII".into()))?;
    let seq: u32 = seq_str
        .trim()
        .parse()
        .map_err(|_| Error::Protocol(format!("guppy seq is not a number: {seq_str:?}")))?;

    Ok((seq, content))
}

/// Send an ACK for `seq`: `<seq>\r\n`.
async fn send_ack(sock: &UdpSocket, seq: u32) -> Result<(), Error> {
    let ack = format!("{seq}\r\n");
    sock.send(ack.as_bytes())
        .await
        .map_err(|e| Error::Io(format!("udp ack {seq}: {e}")))?;
    Ok(())
}

/// Parse a gemini-style `<code> <meta>` header from the seq-0 content.
fn parse_header(header: &str) -> Result<(Status, Option<u8>, String), Error> {
    let bytes = header.as_bytes();
    if bytes.len() < 2 || !bytes[0].is_ascii_digit() || !bytes[1].is_ascii_digit() {
        return Err(Error::Protocol(format!("bad guppy header: {header:?}")));
    }
    let code = (bytes[0] - b'0') * 10 + (bytes[1] - b'0');
    let meta = header.get(2..).unwrap_or("").trim_start().to_string();

    let status = match bytes[0] {
        b'1' => Status::Input,
        b'2' => Status::Success,
        b'3' => Status::Redirect,
        b'4' | b'5' => Status::Failure,
        _ => return Err(Error::Protocol(format!("unknown guppy status: {code}"))),
    };

    Ok((status, Some(code), meta))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_header_success() {
        let (s, raw, meta) = parse_header("20 text/gemini").unwrap();
        assert_eq!(s, Status::Success);
        assert_eq!(raw, Some(20));
        assert_eq!(meta, "text/gemini");
    }

    #[test]
    fn parse_header_redirect() {
        let (s, raw, meta) = parse_header("31 guppy://other/").unwrap();
        assert_eq!(s, Status::Redirect);
        assert_eq!(raw, Some(31));
        assert_eq!(meta, "guppy://other/");
    }

    #[test]
    fn parse_header_failure() {
        let (s, raw, meta) = parse_header("40 not found").unwrap();
        assert_eq!(s, Status::Failure);
        assert_eq!(raw, Some(40));
        assert_eq!(meta, "not found");
    }

    #[test]
    fn parse_header_bad_code_is_protocol_error() {
        assert!(matches!(parse_header("xx oops"), Err(Error::Protocol(_))));
    }

    #[test]
    fn guppy_scheme_routes_to_port_6775() {
        assert_eq!(Scheme::Guppy.default_port(), 6775);
    }
}