Skip to main content

guppy_protocol/
server.rs

1//! The guppy server: one UDP socket, per-peer sessions, windowed
2//! transmission with retransmit-until-acked, per spec v0.4.4.
3
4use std::collections::{BTreeSet, HashMap};
5use std::future::Future;
6use std::hash::{BuildHasher, Hasher, RandomState};
7use std::net::SocketAddr;
8use std::path::PathBuf;
9use std::time::{Duration, Instant};
10
11use percent_encoding::percent_decode_str;
12use tokio::net::UdpSocket;
13use url::Url;
14
15use super::packet::{ClientDatagram, encode_continuation, encode_first, parse_client_datagram};
16use super::{GuppyResponse, MAX_REQUEST_BYTES, MAX_SEQ, MIN_SEQ};
17
18/// One guppy request, as handed to a [`Handler`].
19#[derive(Debug, Clone)]
20pub struct Request {
21    /// The requested URL, verbatim.
22    pub url: Url,
23    /// The URL's path (`/` for an empty path).
24    pub path: String,
25    /// The URL's query component, percent-decoded: the user's input from the
26    /// prompt flow, if any.
27    pub input: Option<String>,
28    /// The client's socket address.
29    pub peer: SocketAddr,
30}
31
32/// The application seam: turn a [`Request`] into a [`GuppyResponse`].
33/// `Success` bodies are chunked, transmitted, and retransmitted by the
34/// server; the other variants go out as single special packets.
35pub trait Handler: Send + Sync + 'static {
36    fn handle(&self, request: Request) -> impl Future<Output = GuppyResponse> + Send;
37}
38
39impl<F, Fut> Handler for F
40where
41    F: Fn(Request) -> Fut + Send + Sync + 'static,
42    Fut: Future<Output = GuppyResponse> + Send,
43{
44    fn handle(&self, request: Request) -> impl Future<Output = GuppyResponse> + Send {
45        self(request)
46    }
47}
48
49/// Server tuning.
50#[derive(Debug, Clone)]
51pub struct ServerConfig {
52    /// Body bytes per continuation packet. The spec recommends ≥ 512 for
53    /// large responses. Default 512.
54    pub chunk_size: usize,
55    /// How many unacknowledged packets may be in flight per session (the
56    /// spec allows transmitting ahead of acks). Default 16.
57    pub window: usize,
58    /// Retransmit unacknowledged packets after this long. Default 500ms.
59    pub retransmit_after: Duration,
60    /// Drop sessions that have not completed within this long. Default 30s.
61    pub session_timeout: Duration,
62    /// Refuse new sessions beyond this many concurrent peers (denial-of-
63    /// service protection, per the spec's sessions section). Default 1024.
64    pub max_sessions: usize,
65}
66
67impl Default for ServerConfig {
68    fn default() -> Self {
69        Self {
70            chunk_size: 512,
71            window: 16,
72            retransmit_after: Duration::from_millis(500),
73            session_timeout: Duration::from_secs(30),
74            max_sessions: 1024,
75        }
76    }
77}
78
79struct Session {
80    /// Every packet of the response, in seq order: `(seq, wire bytes)`.
81    packets: Vec<(u32, Vec<u8>)>,
82    /// Index of the next packet never yet sent.
83    next_unsent: usize,
84    /// Seqs sent but not yet acknowledged.
85    unacked: BTreeSet<u32>,
86    last_send: Instant,
87    created: Instant,
88    /// Set when every packet is acknowledged; the session lingers briefly so
89    /// duplicate request packets stay ignored, per the spec.
90    done_at: Option<Instant>,
91}
92
93const DONE_LINGER: Duration = Duration::from_secs(5);
94
95/// Serve guppy on `socket` through `handler` until `shutdown` resolves.
96///
97/// The handler runs inline on the receive loop (guppy serves small documents;
98/// a handler that must do slow work should do it elsewhere and answer fast).
99pub async fn serve(
100    socket: UdpSocket,
101    handler: impl Handler,
102    config: ServerConfig,
103    shutdown: impl Future<Output = ()>,
104) -> std::io::Result<()> {
105    let mut sessions: HashMap<SocketAddr, Session> = HashMap::new();
106    let mut buffer = vec![0u8; 65_536];
107    let mut tick = tokio::time::interval(config.retransmit_after / 2);
108    tokio::pin!(shutdown);
109
110    loop {
111        tokio::select! {
112            _ = &mut shutdown => break,
113            _ = tick.tick() => {
114                let now = Instant::now();
115                sessions.retain(|_, session| {
116                    let expired = now.duration_since(session.created) > config.session_timeout;
117                    let lingered = session
118                        .done_at
119                        .is_some_and(|done| now.duration_since(done) > DONE_LINGER);
120                    !(expired || lingered)
121                });
122                for (peer, session) in sessions.iter_mut() {
123                    if session.done_at.is_none()
124                        && now.duration_since(session.last_send) >= config.retransmit_after
125                    {
126                        retransmit(&socket, *peer, session).await;
127                    }
128                }
129            }
130            received = socket.recv_from(&mut buffer) => {
131                let (count, peer) = match received {
132                    Ok(received) => received,
133                    Err(error) => {
134                        log::warn!("guppy: recv failed: {error}");
135                        continue;
136                    }
137                };
138                if count > MAX_REQUEST_BYTES {
139                    continue;
140                }
141                let datagram = match parse_client_datagram(&buffer[..count]) {
142                    Ok(datagram) => datagram,
143                    Err(error) => {
144                        log::debug!("guppy: ignoring malformed datagram from {peer}: {error}");
145                        continue;
146                    }
147                };
148                match datagram {
149                    ClientDatagram::Ack(seq) => {
150                        if let Some(session) = sessions.get_mut(&peer) {
151                            acknowledge(&socket, peer, session, seq, &config).await;
152                        }
153                    }
154                    ClientDatagram::Request(line) => {
155                        // Spec: additional request packets in a live session
156                        // are ignored.
157                        if sessions.contains_key(&peer) {
158                            continue;
159                        }
160                        if sessions.len() >= config.max_sessions {
161                            log::warn!("guppy: session limit reached; dropping request from {peer}");
162                            continue;
163                        }
164                        let Some(request) = parse_request(&line, peer) else {
165                            let _ = socket.send_to(b"4 Malformed request URL.\r\n", peer).await;
166                            continue;
167                        };
168                        let response = handler.handle(request).await;
169                        match response {
170                            GuppyResponse::Success { mime, body } => {
171                                let mut session = build_session(&mime, &body, config.chunk_size);
172                                open_window(&socket, peer, &mut session, &config).await;
173                                sessions.insert(peer, session);
174                            }
175                            GuppyResponse::Prompt { text } => {
176                                let _ = socket.send_to(format!("1 {text}\r\n").as_bytes(), peer).await;
177                            }
178                            GuppyResponse::Redirect { target } => {
179                                let _ = socket.send_to(format!("3 {target}\r\n").as_bytes(), peer).await;
180                            }
181                            GuppyResponse::Error { message } => {
182                                let _ = socket.send_to(format!("4 {message}\r\n").as_bytes(), peer).await;
183                            }
184                        }
185                    }
186                }
187            }
188        }
189    }
190    Ok(())
191}
192
193fn parse_request(line: &str, peer: SocketAddr) -> Option<Request> {
194    let url = Url::parse(line.trim()).ok()?;
195    if url.scheme() != "guppy" {
196        return None;
197    }
198    let path = if url.path().is_empty() {
199        "/".to_string()
200    } else {
201        url.path().to_string()
202    };
203    let input = url
204        .query()
205        .map(|query| percent_decode_str(query).decode_utf8_lossy().into_owned());
206    Some(Request {
207        url,
208        path,
209        input,
210        peer,
211    })
212}
213
214/// Chunk a success response into its packet sequence, starting at a random
215/// sequence number low enough that the EOF stays within [`MAX_SEQ`].
216fn build_session(mime: &str, body: &[u8], chunk_size: usize) -> Session {
217    let chunk_size = chunk_size.max(1);
218    // First packet carries the first chunk; the rest are continuations; one
219    // more for EOF.
220    let continuation_count = if body.len() > chunk_size {
221        body.len().div_ceil(chunk_size) - 1
222    } else {
223        0
224    };
225    let total_packets = 1 + continuation_count as u32 + 1;
226    let first_seq = random_seq(total_packets);
227
228    let mut packets = Vec::with_capacity(total_packets as usize);
229    let first_chunk = &body[..body.len().min(chunk_size)];
230    packets.push((first_seq, encode_first(first_seq, mime, first_chunk)));
231    let mut seq = first_seq;
232    let mut offset = first_chunk.len();
233    while offset < body.len() {
234        seq += 1;
235        let end = (offset + chunk_size).min(body.len());
236        packets.push((seq, encode_continuation(seq, &body[offset..end])));
237        offset = end;
238    }
239    // End-of-file: a continuation with no data.
240    packets.push((seq + 1, encode_continuation(seq + 1, &[])));
241
242    Session {
243        packets,
244        next_unsent: 0,
245        unacked: BTreeSet::new(),
246        last_send: Instant::now(),
247        created: Instant::now(),
248        done_at: None,
249    }
250}
251
252/// A dependency-free random first sequence number in
253/// `[MIN_SEQ, MAX_SEQ - total_packets]`.
254fn random_seq(total_packets: u32) -> u32 {
255    let mut hasher = RandomState::new().build_hasher();
256    hasher.write_u128(
257        std::time::SystemTime::now()
258            .duration_since(std::time::UNIX_EPOCH)
259            .map(|elapsed| elapsed.as_nanos())
260            .unwrap_or(0),
261    );
262    let span = MAX_SEQ - total_packets - MIN_SEQ;
263    MIN_SEQ + (hasher.finish() % u64::from(span)) as u32
264}
265
266/// Send unsent packets until the window is full.
267async fn open_window(
268    socket: &UdpSocket,
269    peer: SocketAddr,
270    session: &mut Session,
271    config: &ServerConfig,
272) {
273    while session.unacked.len() < config.window && session.next_unsent < session.packets.len() {
274        let (seq, wire) = &session.packets[session.next_unsent];
275        if socket.send_to(wire, peer).await.is_err() {
276            break;
277        }
278        session.unacked.insert(*seq);
279        session.next_unsent += 1;
280    }
281    session.last_send = Instant::now();
282}
283
284async fn acknowledge(
285    socket: &UdpSocket,
286    peer: SocketAddr,
287    session: &mut Session,
288    seq: u32,
289    config: &ServerConfig,
290) {
291    // Duplicate acks are ignored per the spec (remove is idempotent).
292    session.unacked.remove(&seq);
293    if session.next_unsent == session.packets.len() && session.unacked.is_empty() {
294        if session.done_at.is_none() {
295            session.done_at = Some(Instant::now());
296        }
297        return;
298    }
299    open_window(socket, peer, session, config).await;
300}
301
302async fn retransmit(socket: &UdpSocket, peer: SocketAddr, session: &mut Session) {
303    // Everything in flight and unacknowledged goes again (bounded by the
304    // window size).
305    let unacked: Vec<u32> = session.unacked.iter().copied().collect();
306    for seq in unacked {
307        if let Ok(index) = session
308            .packets
309            .binary_search_by_key(&seq, |(packet_seq, _)| *packet_seq)
310        {
311            let _ = socket.send_to(&session.packets[index].1, peer).await;
312        }
313    }
314    session.last_send = Instant::now();
315}
316
317/// A static-directory [`Handler`]: gemtext-first, `index.gmi` per directory,
318/// traversal-protected, prompt input ignored.
319#[derive(Debug, Clone)]
320pub struct FileHandler {
321    root: PathBuf,
322}
323
324impl FileHandler {
325    pub fn new(root: impl Into<PathBuf>) -> Self {
326        Self { root: root.into() }
327    }
328
329    fn resolve(&self, request_path: &str) -> Option<PathBuf> {
330        let decoded = percent_decode_str(request_path).decode_utf8().ok()?;
331        let mut resolved = self.root.clone();
332        for segment in decoded.split('/') {
333            match segment {
334                "" | "." => continue,
335                ".." => return None,
336                segment if segment.contains(['\\', ':']) => return None,
337                segment => resolved.push(segment),
338            }
339        }
340        if decoded.ends_with('/') {
341            resolved.push("index.gmi");
342        }
343        Some(resolved)
344    }
345}
346
347fn mime_for(path: &std::path::Path) -> &'static str {
348    match path
349        .extension()
350        .and_then(|extension| extension.to_str())
351        .unwrap_or("")
352        .to_ascii_lowercase()
353        .as_str()
354    {
355        "gmi" | "gemini" => "text/gemini",
356        "txt" | "" => "text/plain",
357        "md" => "text/markdown",
358        "png" => "image/png",
359        "jpg" | "jpeg" => "image/jpeg",
360        _ => "application/octet-stream",
361    }
362}
363
364impl Handler for FileHandler {
365    async fn handle(&self, request: Request) -> GuppyResponse {
366        let Some(mut path) = self.resolve(&request.path) else {
367            return GuppyResponse::Error {
368                message: "Bad path.".to_string(),
369            };
370        };
371        if path.is_dir() {
372            path.push("index.gmi");
373        }
374        match tokio::fs::read(&path).await {
375            Ok(body) => GuppyResponse::Success {
376                mime: mime_for(&path).to_string(),
377                body,
378            },
379            Err(_) => GuppyResponse::Error {
380                message: format!("{} not found", request.path),
381            },
382        }
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::client::{FetchOptions, fetch};
390
391    async fn spawn(
392        handler: impl Handler,
393        config: ServerConfig,
394    ) -> (SocketAddr, tokio::sync::oneshot::Sender<()>) {
395        let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
396        let addr = socket.local_addr().unwrap();
397        let (tx, rx) = tokio::sync::oneshot::channel::<()>();
398        tokio::spawn(async move {
399            let _ = serve(socket, handler, config, async {
400                let _ = rx.await;
401            })
402            .await;
403        });
404        (addr, tx)
405    }
406
407    fn options_for(addr: SocketAddr) -> FetchOptions {
408        FetchOptions {
409            connect_addr: Some(addr),
410            timeout: Duration::from_secs(5),
411            retransmit_after: Duration::from_millis(200),
412            ..Default::default()
413        }
414    }
415
416    #[tokio::test]
417    async fn single_packet_round_trip() {
418        let (addr, _stop) = spawn(
419            |_request: Request| async move {
420                GuppyResponse::Success {
421                    mime: "text/gemini".to_string(),
422                    body: b"# Title 1\n".to_vec(),
423                }
424            },
425            ServerConfig::default(),
426        )
427        .await;
428        let response = fetch("guppy://example.test/a", &options_for(addr))
429            .await
430            .unwrap();
431        assert_eq!(
432            response,
433            GuppyResponse::Success {
434                mime: "text/gemini".to_string(),
435                body: b"# Title 1\n".to_vec()
436            }
437        );
438    }
439
440    #[tokio::test]
441    async fn multi_chunk_bodies_reassemble_in_order() {
442        let body: Vec<u8> = (0..=255u8).cycle().take(5000).collect();
443        let expected = body.clone();
444        let (addr, _stop) = spawn(
445            move |_request: Request| {
446                let body = body.clone();
447                async move {
448                    GuppyResponse::Success {
449                        mime: "application/octet-stream".to_string(),
450                        body,
451                    }
452                }
453            },
454            ServerConfig {
455                chunk_size: 64,
456                window: 4,
457                ..Default::default()
458            },
459        )
460        .await;
461        let response = fetch("guppy://example.test/big", &options_for(addr))
462            .await
463            .unwrap();
464        match response {
465            GuppyResponse::Success { body, .. } => assert_eq!(body, expected),
466            other => panic!("expected success, got {other:?}"),
467        }
468    }
469
470    #[tokio::test]
471    async fn prompt_redirect_and_error_round_trip() {
472        let (addr, _stop) = spawn(
473            |request: Request| async move {
474                match (request.path.as_str(), request.input) {
475                    ("/greet", None) => GuppyResponse::Prompt {
476                        text: "Your name".to_string(),
477                    },
478                    ("/greet", Some(name)) => GuppyResponse::Success {
479                        mime: "text/gemini".to_string(),
480                        body: format!("Hello {name}\n").into_bytes(),
481                    },
482                    ("/old", _) => GuppyResponse::Redirect {
483                        target: "/new".to_string(),
484                    },
485                    _ => GuppyResponse::Error {
486                        message: "not found".to_string(),
487                    },
488                }
489            },
490            ServerConfig::default(),
491        )
492        .await;
493
494        assert_eq!(
495            fetch("guppy://example.test/greet", &options_for(addr))
496                .await
497                .unwrap(),
498            GuppyResponse::Prompt {
499                text: "Your name".to_string()
500            }
501        );
502        assert_eq!(
503            fetch("guppy://example.test/greet?Guppy", &options_for(addr))
504                .await
505                .unwrap(),
506            GuppyResponse::Success {
507                mime: "text/gemini".to_string(),
508                body: b"Hello Guppy\n".to_vec()
509            }
510        );
511        assert_eq!(
512            fetch("guppy://example.test/old", &options_for(addr))
513                .await
514                .unwrap(),
515            GuppyResponse::Redirect {
516                target: "/new".to_string()
517            }
518        );
519        assert_eq!(
520            fetch("guppy://example.test/ghost", &options_for(addr))
521                .await
522                .unwrap(),
523            GuppyResponse::Error {
524                message: "not found".to_string()
525            }
526        );
527    }
528
529    /// Drive the client against a raw socket that delivers packets out of
530    /// order and duplicated — the spec's unreliable-network examples.
531    #[tokio::test]
532    async fn client_survives_out_of_order_and_duplicate_delivery() {
533        let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
534        let addr = socket.local_addr().unwrap();
535        tokio::spawn(async move {
536            let mut buffer = vec![0u8; 4096];
537            // Wait for the request.
538            let (_count, peer) = socket.recv_from(&mut buffer).await.unwrap();
539            let first = encode_first(100, "text/plain", b"AA");
540            let second = encode_continuation(101, b"BB");
541            let eof = encode_continuation(102, b"");
542            // Out of order, with duplicates: 101, 100, 101, 102(eof), 100.
543            for wire in [&second, &first, &second, &eof, &first] {
544                socket.send_to(wire, peer).await.unwrap();
545            }
546            // Absorb acks so the test socket doesn't fill any queue.
547            loop {
548                if socket.recv_from(&mut buffer).await.is_err() {
549                    break;
550                }
551            }
552        });
553
554        let response = fetch("guppy://example.test/x", &options_for(addr))
555            .await
556            .unwrap();
557        assert_eq!(
558            response,
559            GuppyResponse::Success {
560                mime: "text/plain".to_string(),
561                body: b"AABB".to_vec()
562            }
563        );
564    }
565
566    #[tokio::test]
567    async fn file_handler_serves_and_refuses_traversal() {
568        let dir = tempfile::TempDir::new().unwrap();
569        std::fs::write(dir.path().join("index.gmi"), "# Hi\n").unwrap();
570        let (addr, _stop) = spawn(FileHandler::new(dir.path()), ServerConfig::default()).await;
571
572        assert_eq!(
573            fetch("guppy://example.test/", &options_for(addr))
574                .await
575                .unwrap(),
576            GuppyResponse::Success {
577                mime: "text/gemini".to_string(),
578                body: b"# Hi\n".to_vec()
579            }
580        );
581
582        // URL parsing normalizes ".."; hostile selectors arrive raw.
583        let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
584        socket.connect(addr).await.unwrap();
585        socket
586            .send(b"guppy://example.test/%2e%2e/secret\r\n")
587            .await
588            .unwrap();
589        let mut buffer = vec![0u8; 4096];
590        let count = tokio::time::timeout(Duration::from_secs(2), socket.recv(&mut buffer))
591            .await
592            .unwrap()
593            .unwrap();
594        assert!(buffer[..count].starts_with(b"4 "), "traversal answered with an error");
595    }
596
597    #[test]
598    fn build_session_chunks_and_terminates() {
599        let session = build_session("text/plain", b"0123456789", 4);
600        assert_eq!(session.packets.len(), 4, "3 data chunks + EOF");
601        let seqs: Vec<u32> = session.packets.iter().map(|(seq, _)| *seq).collect();
602        assert!(seqs.windows(2).all(|pair| pair[1] == pair[0] + 1));
603        assert!(seqs[0] >= MIN_SEQ);
604        // EOF packet is header-only.
605        let (_, eof_wire) = session.packets.last().unwrap();
606        assert!(eof_wire.ends_with(b"\r\n"));
607    }
608
609    #[test]
610    fn random_seq_stays_in_range() {
611        for _ in 0..100 {
612            let seq = random_seq(1000);
613            assert!(seq >= MIN_SEQ && seq <= MAX_SEQ - 1000);
614        }
615    }
616}