1use 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#[derive(Debug, Clone)]
20pub struct Request {
21 pub url: Url,
23 pub path: String,
25 pub input: Option<String>,
28 pub peer: SocketAddr,
30}
31
32pub 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#[derive(Debug, Clone)]
51pub struct ServerConfig {
52 pub chunk_size: usize,
55 pub window: usize,
58 pub retransmit_after: Duration,
60 pub session_timeout: Duration,
62 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 packets: Vec<(u32, Vec<u8>)>,
82 next_unsent: usize,
84 unacked: BTreeSet<u32>,
86 last_send: Instant,
87 created: Instant,
88 done_at: Option<Instant>,
91}
92
93const DONE_LINGER: Duration = Duration::from_secs(5);
94
95pub 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 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
214fn build_session(mime: &str, body: &[u8], chunk_size: usize) -> Session {
217 let chunk_size = chunk_size.max(1);
218 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 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
252fn 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
266async 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 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 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#[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 #[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 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 for wire in [&second, &first, &second, &eof, &first] {
544 socket.send_to(wire, peer).await.unwrap();
545 }
546 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 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 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}