use tokio::net::UdpSocket;
use url::Url;
use crate::{Error, Response, Scheme, Status};
const MAX_DATAGRAM: usize = 1400;
const RECV_TIMEOUT_MS: u64 = 5_000;
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());
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}")))?;
let request = format!("{url}\r\n");
sock.send(request.as_bytes())
.await
.map_err(|e| Error::Io(format!("send request: {e}")))?;
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}"
)));
}
send_ack(&sock, 0).await?;
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() });
}
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 })
}
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];
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))
}
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(())
}
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);
}
}