Skip to main content

guppy_protocol/
packet.rs

1//! Guppy packet wire forms: `<header>\r\n[<data>]`.
2
3/// A parsed server→client packet.
4#[derive(Debug, Clone, PartialEq, Eq)]
5pub enum Packet {
6    /// `<seq> <mimetype>\r\n<data>` — the first packet of a success.
7    First {
8        seq: u32,
9        mime: String,
10        data: Vec<u8>,
11    },
12    /// `<seq>\r\n<data>` — a continuation; empty data marks end-of-file.
13    Continuation { seq: u32, data: Vec<u8> },
14    /// `1 <prompt>\r\n`
15    Prompt { text: String },
16    /// `3 <url>\r\n`
17    Redirect { target: String },
18    /// `4 <error>\r\n`
19    Error { message: String },
20}
21
22/// Parse a server→client packet.
23///
24/// Disambiguation, per the spec: success sequence numbers are ≥ 6, but the
25/// spec warns clients not to confuse e.g. seq 39 with a redirect — the
26/// distinction is that special packets have the literal single digit `1`,
27/// `3`, or `4` as their whole first token, and success first-packets carry a
28/// non-empty MIME token after the space, while continuations have a bare
29/// numeric header.
30pub fn parse_packet(datagram: &[u8]) -> Result<Packet, String> {
31    let split = datagram
32        .windows(2)
33        .position(|window| window == b"\r\n")
34        .ok_or_else(|| "packet has no CRLF".to_string())?;
35    let header = std::str::from_utf8(&datagram[..split])
36        .map_err(|_| "packet header is not UTF-8".to_string())?;
37    let data = datagram[split + 2..].to_vec();
38
39    match header.split_once(' ') {
40        Some(("1", rest)) => Ok(Packet::Prompt {
41            text: rest.trim().to_string(),
42        }),
43        Some(("3", rest)) => Ok(Packet::Redirect {
44            target: rest.trim().to_string(),
45        }),
46        Some(("4", rest)) => Ok(Packet::Error {
47            message: rest.trim().to_string(),
48        }),
49        Some((seq, mime)) => {
50            let seq: u32 = seq
51                .parse()
52                .map_err(|_| format!("bad sequence number: {seq:?}"))?;
53            if mime.trim().is_empty() {
54                return Err("first packet has an empty MIME type".to_string());
55            }
56            Ok(Packet::First {
57                seq,
58                mime: mime.trim().to_string(),
59                data,
60            })
61        }
62        None => {
63            let seq: u32 = header
64                .trim()
65                .parse()
66                .map_err(|_| format!("bad packet header: {header:?}"))?;
67            Ok(Packet::Continuation { seq, data })
68        }
69    }
70}
71
72/// Encode the first packet of a success.
73pub(crate) fn encode_first(seq: u32, mime: &str, data: &[u8]) -> Vec<u8> {
74    let mut out = format!("{seq} {mime}\r\n").into_bytes();
75    out.extend_from_slice(data);
76    out
77}
78
79/// Encode a continuation (empty `data` encodes the end-of-file packet).
80pub(crate) fn encode_continuation(seq: u32, data: &[u8]) -> Vec<u8> {
81    let mut out = format!("{seq}\r\n").into_bytes();
82    out.extend_from_slice(data);
83    out
84}
85
86/// Encode an acknowledgement (client→server): `<seq>\r\n`.
87pub(crate) fn encode_ack(seq: u32) -> Vec<u8> {
88    format!("{seq}\r\n").into_bytes()
89}
90
91/// Parse a client→server datagram: an acknowledgement (a bare integer line
92/// with no data) or a request (a URL line).
93pub(crate) enum ClientDatagram {
94    Ack(u32),
95    Request(String),
96}
97
98pub(crate) fn parse_client_datagram(datagram: &[u8]) -> Result<ClientDatagram, String> {
99    let split = datagram
100        .windows(2)
101        .position(|window| window == b"\r\n")
102        .ok_or_else(|| "datagram has no CRLF".to_string())?;
103    let line = std::str::from_utf8(&datagram[..split])
104        .map_err(|_| "datagram is not UTF-8".to_string())?;
105    if datagram.len() == split + 2 {
106        if let Ok(seq) = line.trim().parse::<u32>() {
107            return Ok(ClientDatagram::Ack(seq));
108        }
109    }
110    Ok(ClientDatagram::Request(line.to_string()))
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn spec_example_packets_parse() {
119        // From the spec's examples section.
120        assert_eq!(
121            parse_packet(b"566837578 text/gemini\r\n# Title 1\n").unwrap(),
122            Packet::First {
123                seq: 566_837_578,
124                mime: "text/gemini".to_string(),
125                data: b"# Title 1\n".to_vec()
126            }
127        );
128        assert_eq!(
129            parse_packet(b"566837579\r\nParagraph 1").unwrap(),
130            Packet::Continuation {
131                seq: 566_837_579,
132                data: b"Paragraph 1".to_vec()
133            }
134        );
135        assert_eq!(
136            parse_packet(b"566837581\r\n").unwrap(),
137            Packet::Continuation {
138                seq: 566_837_581,
139                data: Vec::new()
140            }
141        );
142        assert_eq!(
143            parse_packet(b"1 Your name\r\n").unwrap(),
144            Packet::Prompt {
145                text: "Your name".to_string()
146            }
147        );
148        assert_eq!(
149            parse_packet(b"3 /b\r\n").unwrap(),
150            Packet::Redirect {
151                target: "/b".to_string()
152            }
153        );
154        assert_eq!(
155            parse_packet(b"4 No search keywords specified\r\n").unwrap(),
156            Packet::Error {
157                message: "No search keywords specified".to_string()
158            }
159        );
160    }
161
162    #[test]
163    fn low_sequence_numbers_with_mime_are_success_not_special() {
164        // The spec's explicit warning: don't confuse seq 39/41 with 3/4.
165        assert!(matches!(
166            parse_packet(b"39 text/plain\r\nhi").unwrap(),
167            Packet::First { seq: 39, .. }
168        ));
169        assert!(matches!(
170            parse_packet(b"41 text/plain\r\nhi").unwrap(),
171            Packet::First { seq: 41, .. }
172        ));
173    }
174
175    #[test]
176    fn client_datagrams_split_into_acks_and_requests() {
177        assert!(matches!(
178            parse_client_datagram(b"566837578\r\n").unwrap(),
179            ClientDatagram::Ack(566_837_578)
180        ));
181        assert!(matches!(
182            parse_client_datagram(b"guppy://localhost/a\r\n").unwrap(),
183            ClientDatagram::Request(url) if url == "guppy://localhost/a"
184        ));
185    }
186
187    #[test]
188    fn encodings_round_trip() {
189        let first = encode_first(42, "text/gemini", b"body");
190        assert!(matches!(
191            parse_packet(&first).unwrap(),
192            Packet::First { seq: 42, .. }
193        ));
194        let eof = encode_continuation(43, b"");
195        assert_eq!(
196            parse_packet(&eof).unwrap(),
197            Packet::Continuation {
198                seq: 43,
199                data: Vec::new()
200            }
201        );
202        assert_eq!(encode_ack(42), b"42\r\n");
203    }
204}