Skip to main content

gopher_protocol/
client.rs

1//! The gopher client (`gopher://`, port 70).
2//!
3//! A gopher URL is `gopher://host/<type><selector>`: the first path character is
4//! the item type, the rest is the selector sent verbatim. The request is just
5//! the selector and a CRLF; a type-7 search appends the query after a TAB.
6//!
7//! Gopher has no status line. Every reply is a body, and the item type is the
8//! only hint about what the bytes are, so the client reports a best-effort
9//! MIME alongside them rather than inventing a status the protocol lacks.
10//!
11//! ```no_run
12//! # async fn run() -> Result<(), gopher_protocol::ClientError> {
13//! let reply = gopher_protocol::fetch("gopher://gopher.floodgap.com/1/").await?;
14//! if reply.mime == "application/gopher-menu" {
15//!     for item in gopher_protocol::parse_menu(&String::from_utf8_lossy(&reply.body)) {
16//!         println!("{:?} {}", item.kind, item.display);
17//!     }
18//! }
19//! # Ok(())
20//! # }
21//! ```
22
23use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
24use tokio::net::TcpStream;
25use url::Url;
26
27use crate::plus::{AttributeBlock, MalformedHeader, PlusHeader, parse_attributes, parse_header};
28
29// Re-exported so `client::PlusRequest` remains a valid path after the type
30// moved to `plus`, where it belongs: a request form is protocol vocabulary,
31// not client machinery, and the server needs it too.
32pub use crate::plus::PlusRequest;
33
34/// Gopher's well-known port.
35pub const DEFAULT_PORT: u16 = 70;
36
37/// What can go wrong fetching a gopher resource. There is no protocol-error
38/// variant because gopher has no status line: a server that dislikes a request
39/// answers with an error *item* inside an ordinary menu body.
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub enum ClientError {
42    /// The URL could not be parsed, or it lacks a host.
43    BadUrl(String),
44    /// The TCP connection could not be established.
45    Connect(String),
46    /// A read or write failed mid-exchange.
47    Io(String),
48    /// A Gopher+ reply did not begin with a well-formed header. Only Gopher+
49    /// transactions can produce this; plain RFC 1436 replies have no header.
50    BadPlusHeader(String),
51    /// A Gopher+ server answered `--1`. The string is the error text it sent.
52    PlusError(String),
53}
54
55impl From<MalformedHeader> for ClientError {
56    fn from(error: MalformedHeader) -> Self {
57        Self::BadPlusHeader(error.0)
58    }
59}
60
61impl std::fmt::Display for ClientError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            Self::BadUrl(m) => write!(f, "bad url: {m}"),
65            Self::Connect(m) => write!(f, "connect: {m}"),
66            Self::Io(m) => write!(f, "io: {m}"),
67            Self::BadPlusHeader(m) => write!(f, "malformed gopher+ header: {m}"),
68            Self::PlusError(m) => write!(f, "gopher+ error: {m}"),
69        }
70    }
71}
72
73impl std::error::Error for ClientError {}
74
75/// One gopher reply: the body, plus the MIME inferred from the item type.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct Response {
78    /// A best-effort MIME type from the requested item type. Menus report
79    /// `application/gopher-menu` so a consumer can route them to
80    /// [`crate::menu::parse`].
81    pub mime: String,
82    /// The reply body, read to EOF.
83    pub body: Vec<u8>,
84}
85
86/// Fetch a `gopher://` URL.
87///
88/// The URL is taken as a string so this signature does not carry a `url`
89/// major version into the public API.
90pub async fn fetch(url: &str) -> Result<Response, ClientError> {
91    let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
92    let host = url
93        .host_str()
94        .ok_or_else(|| ClientError::BadUrl("gopher URL has no host".into()))?;
95    let port = url.port().unwrap_or(DEFAULT_PORT);
96    let (item_type, selector) = split_path(&url);
97
98    let mut request = selector;
99    // A type-7 item is a search server: the query rides after a TAB.
100    if let Some(query) = url.query() {
101        request.push('\t');
102        request.push_str(query);
103    }
104    request.push_str("\r\n");
105
106    let body = exchange(host, port, request.as_bytes()).await?;
107    Ok(Response {
108        mime: mime_for_item_type(item_type).to_string(),
109        body,
110    })
111}
112
113/// Open a plaintext TCP connection, send `request`, and read the whole reply to
114/// EOF (gopher servers close the stream when done).
115async fn exchange(host: &str, port: u16, request: &[u8]) -> Result<Vec<u8>, ClientError> {
116    let mut stream = TcpStream::connect((host, port))
117        .await
118        .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
119    stream
120        .write_all(request)
121        .await
122        .map_err(|e| ClientError::Io(e.to_string()))?;
123    let mut buf = Vec::new();
124    stream
125        .read_to_end(&mut buf)
126        .await
127        .map_err(|e| ClientError::Io(e.to_string()))?;
128    Ok(buf)
129}
130
131/// Split a gopher path into its item-type character and selector. An empty path
132/// is the root menu (type `1`, empty selector).
133fn split_path(url: &Url) -> (char, String) {
134    let path = url.path();
135    let trimmed = path.strip_prefix('/').unwrap_or(path);
136    let mut chars = trimmed.chars();
137    match chars.next() {
138        Some(item_type) => (item_type, chars.as_str().to_string()),
139        None => ('1', String::new()),
140    }
141}
142
143/// A best-effort MIME type for a gopher item type. Menus get an
144/// `application/gopher-menu` type so a consumer can route them to a gophermap
145/// renderer; unknown types fall back to opaque bytes.
146///
147/// This is a client convention, not part of RFC 1436 — gopher carries no MIME.
148pub fn mime_for_item_type(item_type: char) -> &'static str {
149    match item_type {
150        '0' => "text/plain",
151        '1' | '7' => "application/gopher-menu",
152        'h' => "text/html",
153        'g' => "image/gif",
154        'I' | ':' => "image/*",
155        's' | '<' => "audio/*",
156        _ => "application/octet-stream",
157    }
158}
159
160// ── Gopher+ ────────────────────────────────────────────────────────────────
161
162/// A Gopher+ reply: the header the server declared, and the body with any
163/// period terminator removed.
164#[derive(Clone, Debug, PartialEq, Eq)]
165pub struct PlusReply {
166    pub header: PlusHeader,
167    pub body: Vec<u8>,
168}
169
170/// Run a Gopher+ transaction against a `gopher://` URL.
171///
172/// A Gopher+ request is the RFC 1436 request with a second TAB and a token:
173/// `selector <TAB> search <TAB> token`. The search field is present but empty
174/// for a non-search item, which is why the spec's own examples show two tabs.
175pub async fn fetch_plus(url: &str, request: PlusRequest) -> Result<PlusReply, ClientError> {
176    let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
177    let host = url
178        .host_str()
179        .ok_or_else(|| ClientError::BadUrl("gopher URL has no host".into()))?;
180    let port = url.port().unwrap_or(DEFAULT_PORT);
181    let (_, selector) = split_path(&url);
182    let search = url.query().unwrap_or("");
183
184    let line = format!("{selector}\t{search}\t{}\r\n", request.token());
185    let (header, body) = plus_exchange(host, port, line.as_bytes()).await?;
186
187    if header == PlusHeader::Error {
188        return Err(ClientError::PlusError(
189            String::from_utf8_lossy(&body).trim().to_string(),
190        ));
191    }
192    Ok(PlusReply { header, body })
193}
194
195/// Fetch and parse an item's Gopher+ attribute blocks (`!`).
196pub async fn fetch_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
197    let reply = fetch_plus(url, PlusRequest::Attributes).await?;
198    Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
199}
200
201/// Fetch and parse the attribute blocks of every item in a directory (`$`).
202pub async fn fetch_directory_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
203    let reply = fetch_plus(url, PlusRequest::DirectoryAttributes).await?;
204    Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
205}
206
207/// Send a Gopher+ request and read the reply according to its header, rather
208/// than always reading to EOF: a counted body stops at its count.
209async fn plus_exchange(
210    host: &str,
211    port: u16,
212    request: &[u8],
213) -> Result<(PlusHeader, Vec<u8>), ClientError> {
214    let mut stream = TcpStream::connect((host, port))
215        .await
216        .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
217    stream
218        .write_all(request)
219        .await
220        .map_err(|e| ClientError::Io(e.to_string()))?;
221
222    let mut reader = BufReader::new(stream);
223    let mut header_line = String::new();
224    reader
225        .read_line(&mut header_line)
226        .await
227        .map_err(|e| ClientError::Io(e.to_string()))?;
228    if header_line.is_empty() {
229        return Err(ClientError::BadPlusHeader(
230            "the server closed without a header".into(),
231        ));
232    }
233    let header = parse_header(&header_line)?;
234
235    let mut body = Vec::new();
236    match header {
237        // `take` rather than a pre-sized allocation: the count comes from the
238        // server and a hostile one should not be able to ask for a huge Vec.
239        PlusHeader::Length(count) => {
240            reader
241                .take(count)
242                .read_to_end(&mut body)
243                .await
244                .map_err(|e| ClientError::Io(e.to_string()))?;
245        },
246        _ => {
247            reader
248                .read_to_end(&mut body)
249                .await
250                .map_err(|e| ClientError::Io(e.to_string()))?;
251        },
252    }
253
254    if matches!(header, PlusHeader::PeriodTerminated | PlusHeader::Error) {
255        body = strip_period_terminator(body);
256    }
257    Ok((header, body))
258}
259
260/// Remove a trailing `.` line. The newline before it belongs to the last data
261/// line, so only the terminator line itself goes.
262fn strip_period_terminator(mut body: Vec<u8>) -> Vec<u8> {
263    for terminator in [
264        b"\r\n.\r\n".as_slice(),
265        b"\n.\n".as_slice(),
266        b"\r\n.".as_slice(),
267        b"\n.".as_slice(),
268    ] {
269        if body.ends_with(terminator) {
270            let keep = if terminator.starts_with(b"\r\n") { 2 } else { 1 };
271            body.truncate(body.len() - terminator.len() + keep);
272            return body;
273        }
274    }
275    body
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    fn split(u: &str) -> (char, String) {
283        split_path(&Url::parse(u).unwrap())
284    }
285
286    #[test]
287    fn root_path_is_a_menu() {
288        assert_eq!(split("gopher://example.org/"), ('1', String::new()));
289        assert_eq!(split("gopher://example.org"), ('1', String::new()));
290    }
291
292    #[test]
293    fn type_and_selector_split_at_the_first_char() {
294        assert_eq!(
295            split("gopher://example.org/0/about.txt"),
296            ('0', "/about.txt".into())
297        );
298        assert_eq!(split("gopher://example.org/1/dir"), ('1', "/dir".into()));
299    }
300
301    #[test]
302    fn mime_inference() {
303        assert_eq!(mime_for_item_type('0'), "text/plain");
304        assert_eq!(mime_for_item_type('1'), "application/gopher-menu");
305        assert_eq!(mime_for_item_type('9'), "application/octet-stream");
306    }
307
308    #[tokio::test]
309    async fn a_url_without_a_host_is_refused_before_connecting() {
310        let error = fetch("gopher:///0/x").await.unwrap_err();
311        assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
312    }
313
314    #[test]
315    fn plus_tokens_match_the_spec() {
316        assert_eq!(PlusRequest::Item(None).token(), "+");
317        assert_eq!(
318            PlusRequest::Item(Some("text/plain".into())).token(),
319            "+text/plain"
320        );
321        assert_eq!(PlusRequest::Attributes.token(), "!");
322        assert_eq!(PlusRequest::DirectoryAttributes.token(), "$");
323    }
324
325    #[test]
326    fn the_period_terminator_goes_but_the_last_newline_stays() {
327        assert_eq!(
328            strip_period_terminator(b"one\r\ntwo\r\n.\r\n".to_vec()),
329            b"one\r\ntwo\r\n".to_vec()
330        );
331        assert_eq!(
332            strip_period_terminator(b"one\ntwo\n.\n".to_vec()),
333            b"one\ntwo\n".to_vec()
334        );
335    }
336
337    #[test]
338    fn a_body_that_merely_ends_in_a_period_is_left_alone() {
339        assert_eq!(
340            strip_period_terminator(b"see fig. 1.".to_vec()),
341            b"see fig. 1.".to_vec()
342        );
343    }
344}