Skip to main content

guppy_protocol/
client.rs

1//! The guppy client: request datagram out, ack-and-reassemble the response.
2
3use std::collections::BTreeMap;
4use std::time::Duration;
5
6use tokio::net::UdpSocket;
7use url::Url;
8
9use super::packet::{Packet, encode_ack, parse_packet};
10use super::{GUPPY_PORT, GuppyResponse, MAX_REQUEST_BYTES};
11
12/// Options for a [`fetch`].
13#[derive(Debug, Clone)]
14pub struct FetchOptions {
15    /// Overall deadline for the whole transaction. Default 30s.
16    pub timeout: Duration,
17    /// Retransmit the request / re-acknowledge if nothing arrives for this
18    /// long. Default 1s.
19    pub retransmit_after: Duration,
20    /// Refuse reassembled bodies larger than this (also bounds the buffer
21    /// held for out-of-order chunks). Default 16 MiB.
22    pub max_body: usize,
23    /// Send to this address instead of resolving the URL's host. For tests
24    /// and odd deployments.
25    pub connect_addr: Option<std::net::SocketAddr>,
26}
27
28impl Default for FetchOptions {
29    fn default() -> Self {
30        Self {
31            timeout: Duration::from_secs(30),
32            retransmit_after: Duration::from_secs(1),
33            max_body: 16 * 1024 * 1024,
34            connect_addr: None,
35        }
36    }
37}
38
39/// Why a fetch failed before a [`GuppyResponse`] was obtained.
40#[derive(Debug)]
41pub enum ClientError {
42    BadUrl(String),
43    RequestTooLong { request_bytes: usize, max: usize },
44    Io(String),
45    /// The overall deadline elapsed. Per the spec, only the EOF packet
46    /// distinguishes a complete response from a truncated one, so a timeout
47    /// means the response cannot be trusted.
48    Timeout,
49    Protocol(String),
50    BodyTooLarge { max: usize },
51}
52
53impl std::fmt::Display for ClientError {
54    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        match self {
56            Self::BadUrl(message) => write!(formatter, "bad guppy URL: {message}"),
57            Self::RequestTooLong { request_bytes, max } => write!(
58                formatter,
59                "guppy request is {request_bytes} bytes (max {max})"
60            ),
61            Self::Io(message) => write!(formatter, "guppy IO error: {message}"),
62            Self::Timeout => write!(formatter, "guppy transaction timed out"),
63            Self::Protocol(message) => write!(formatter, "guppy protocol error: {message}"),
64            Self::BodyTooLarge { max } => {
65                write!(formatter, "guppy response exceeds {max} bytes")
66            }
67        }
68    }
69}
70
71impl std::error::Error for ClientError {}
72
73/// Fetch a `guppy://` URL. User input goes in the URL's query component,
74/// percent-encoded (the input-prompt flow: a [`GuppyResponse::Prompt`] answer
75/// means "repeat the request with input attached").
76pub async fn fetch(url: &str, options: &FetchOptions) -> Result<GuppyResponse, ClientError> {
77    let parsed = Url::parse(url).map_err(|error| ClientError::BadUrl(error.to_string()))?;
78    if parsed.scheme() != "guppy" {
79        return Err(ClientError::BadUrl(format!(
80            "expected guppy:// scheme, got {}://",
81            parsed.scheme()
82        )));
83    }
84    let host = parsed
85        .host_str()
86        .ok_or_else(|| ClientError::BadUrl("URL has no host".to_string()))?;
87    let port = parsed.port().unwrap_or(GUPPY_PORT);
88
89    let request = format!("{url}\r\n");
90    if request.len() > MAX_REQUEST_BYTES {
91        return Err(ClientError::RequestTooLong {
92            request_bytes: request.len(),
93            max: MAX_REQUEST_BYTES,
94        });
95    }
96
97    // The spec's session rule: one source port for the whole transaction —
98    // one bound socket per fetch.
99    let socket = UdpSocket::bind("0.0.0.0:0")
100        .await
101        .map_err(|error| ClientError::Io(format!("bind: {error}")))?;
102    match options.connect_addr {
103        Some(addr) => socket.connect(addr).await,
104        None => socket.connect((host, port)).await,
105    }
106    .map_err(|error| ClientError::Io(format!("connect: {error}")))?;
107
108    tokio::time::timeout(options.timeout, transact(&socket, &request, options))
109        .await
110        .map_err(|_| ClientError::Timeout)?
111}
112
113/// Reassembly state for one success response.
114struct Reassembly {
115    first_seq: u32,
116    mime: String,
117    /// Data chunks keyed by seq (the first packet's chunk included).
118    chunks: BTreeMap<u32, Vec<u8>>,
119    /// Highest seq such that every seq in `first_seq..=contiguous_end` is
120    /// present.
121    contiguous_end: u32,
122    /// Total buffered bytes (bounds memory against hostile senders).
123    buffered: usize,
124    /// The empty continuation's seq, once seen.
125    eof_seq: Option<u32>,
126}
127
128impl Reassembly {
129    fn insert(&mut self, seq: u32, data: Vec<u8>) {
130        self.buffered += data.len();
131        self.chunks.entry(seq).or_insert(data);
132        while self.chunks.contains_key(&(self.contiguous_end + 1)) {
133            self.contiguous_end += 1;
134        }
135    }
136
137    fn complete(&self) -> bool {
138        self.eof_seq == Some(self.contiguous_end + 1)
139    }
140
141    fn into_response(self) -> GuppyResponse {
142        let mut body = Vec::with_capacity(self.buffered);
143        for (_, chunk) in self.chunks {
144            body.extend_from_slice(&chunk);
145        }
146        GuppyResponse::Success {
147            mime: self.mime,
148            body,
149        }
150    }
151}
152
153async fn transact(
154    socket: &UdpSocket,
155    request: &str,
156    options: &FetchOptions,
157) -> Result<GuppyResponse, ClientError> {
158    let send = |bytes: Vec<u8>| async move {
159        socket
160            .send(&bytes)
161            .await
162            .map_err(|error| ClientError::Io(format!("send: {error}")))
163    };
164
165    send(request.as_bytes().to_vec()).await?;
166
167    let mut state: Option<Reassembly> = None;
168    let mut buffer = vec![0u8; 65_536];
169
170    loop {
171        if let Some(reassembly) = &state {
172            if reassembly.complete() {
173                return Ok(state.take().expect("checked").into_response());
174            }
175            if reassembly.buffered > options.max_body {
176                return Err(ClientError::BodyTooLarge {
177                    max: options.max_body,
178                });
179            }
180        }
181
182        let received =
183            tokio::time::timeout(options.retransmit_after, socket.recv(&mut buffer)).await;
184        let count = match received {
185            Ok(Ok(count)) => count,
186            Ok(Err(error)) => return Err(ClientError::Io(format!("recv: {error}"))),
187            Err(_) => {
188                // Nothing arrived for a while. Per the spec: re-transmit the
189                // request if the response hasn't started, else re-acknowledge
190                // the stall point (lost acks are the usual cause).
191                match &state {
192                    None => {
193                        send(request.as_bytes().to_vec()).await?;
194                    }
195                    Some(reassembly) => {
196                        send(encode_ack(reassembly.contiguous_end)).await?;
197                        if let Some(eof) = reassembly.eof_seq {
198                            send(encode_ack(eof)).await?;
199                        }
200                    }
201                }
202                continue;
203            }
204        };
205
206        let packet = match parse_packet(&buffer[..count]) {
207            Ok(packet) => packet,
208            Err(error) => {
209                log::debug!("guppy: ignoring malformed packet: {error}");
210                continue;
211            }
212        };
213
214        match packet {
215            // Special packets are only meaningful as the whole response;
216            // after a success has started they are stray and ignored.
217            Packet::Prompt { text } if state.is_none() => {
218                return Ok(GuppyResponse::Prompt { text });
219            }
220            Packet::Redirect { target } if state.is_none() => {
221                return Ok(GuppyResponse::Redirect { target });
222            }
223            Packet::Error { message } if state.is_none() => {
224                return Ok(GuppyResponse::Error { message });
225            }
226            Packet::Prompt { .. } | Packet::Redirect { .. } | Packet::Error { .. } => {}
227            Packet::First { seq, mime, data } => {
228                // Always ack, duplicates included (a duplicate means our ack
229                // was lost).
230                send(encode_ack(seq)).await?;
231                if state.is_none() {
232                    let mut reassembly = Reassembly {
233                        first_seq: seq,
234                        mime,
235                        chunks: BTreeMap::new(),
236                        contiguous_end: seq,
237                        buffered: 0,
238                        eof_seq: None,
239                    };
240                    reassembly.buffered = data.len();
241                    reassembly.chunks.insert(seq, data);
242                    state = Some(reassembly);
243                }
244            }
245            Packet::Continuation { seq, data } => {
246                // A continuation before the first packet cannot be anchored;
247                // cache-less clients are allowed to ignore it (the server
248                // retransmits).
249                let Some(reassembly) = &mut state else {
250                    continue;
251                };
252                if seq <= reassembly.first_seq {
253                    continue;
254                }
255                send(encode_ack(seq)).await?;
256                if data.is_empty() {
257                    reassembly.eof_seq = Some(seq);
258                } else {
259                    reassembly.insert(seq, data);
260                }
261            }
262        }
263    }
264}