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(&url, 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 connection (plaintext for `gopher://`, TLS for `gophers://`), send
114/// `request`, and read the whole reply to EOF (gopher servers close the
115/// stream when done).
116async fn exchange(
117    url: &Url,
118    host: &str,
119    port: u16,
120    request: &[u8],
121) -> Result<Vec<u8>, ClientError> {
122    if url.scheme() == "gophers" {
123        #[cfg(feature = "tls")]
124        {
125            let mut stream = crate::tls::connect(host, port).await?;
126            return send_and_read(&mut stream, request).await;
127        }
128        #[cfg(not(feature = "tls"))]
129        {
130            // Refusing beats silently sending a gophers:// request in the
131            // clear, which is what ignoring the scheme would do.
132            return Err(ClientError::BadUrl(
133                "gophers:// needs the `tls` feature".into(),
134            ));
135        }
136    }
137    let mut stream = TcpStream::connect((host, port))
138        .await
139        .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
140    send_and_read(&mut stream, request).await
141}
142
143/// Send a request over an already-connected stream and read the reply to EOF.
144async fn send_and_read<S>(stream: &mut S, request: &[u8]) -> Result<Vec<u8>, ClientError>
145where
146    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
147{
148    stream
149        .write_all(request)
150        .await
151        .map_err(|e| ClientError::Io(e.to_string()))?;
152    let mut buf = Vec::new();
153    stream
154        .read_to_end(&mut buf)
155        .await
156        .map_err(|e| ClientError::Io(e.to_string()))?;
157    Ok(buf)
158}
159
160/// Split a gopher path into its item-type character and selector. An empty path
161/// is the root menu (type `1`, empty selector).
162fn split_path(url: &Url) -> (char, String) {
163    let path = url.path();
164    let trimmed = path.strip_prefix('/').unwrap_or(path);
165    let mut chars = trimmed.chars();
166    match chars.next() {
167        Some(item_type) => (item_type, chars.as_str().to_string()),
168        None => ('1', String::new()),
169    }
170}
171
172/// A best-effort MIME type for a gopher item type. Menus get an
173/// `application/gopher-menu` type so a consumer can route them to a gophermap
174/// renderer; unknown types fall back to opaque bytes.
175///
176/// This is a client convention, not part of RFC 1436 — gopher carries no MIME.
177pub fn mime_for_item_type(item_type: char) -> &'static str {
178    match item_type {
179        '0' => "text/plain",
180        '1' | '7' => "application/gopher-menu",
181        'h' => "text/html",
182        'g' => "image/gif",
183        'I' | ':' => "image/*",
184        's' | '<' => "audio/*",
185        _ => "application/octet-stream",
186    }
187}
188
189// ── Gopher+ ────────────────────────────────────────────────────────────────
190
191/// A Gopher+ reply: the header the server declared, and the body with any
192/// period terminator removed.
193#[derive(Clone, Debug, PartialEq, Eq)]
194pub struct PlusReply {
195    pub header: PlusHeader,
196    pub body: Vec<u8>,
197}
198
199/// Run a Gopher+ transaction against a `gopher://` URL.
200///
201/// A Gopher+ request is the RFC 1436 request with a second TAB and a token:
202/// `selector <TAB> search <TAB> token`. The search field is present but empty
203/// for a non-search item, which is why the spec's own examples show two tabs.
204pub async fn fetch_plus(url: &str, request: PlusRequest) -> Result<PlusReply, ClientError> {
205    let url = Url::parse(url).map_err(|e| ClientError::BadUrl(e.to_string()))?;
206    let host = url
207        .host_str()
208        .ok_or_else(|| ClientError::BadUrl("gopher URL has no host".into()))?;
209    let port = url.port().unwrap_or(DEFAULT_PORT);
210    let (_, selector) = split_path(&url);
211    let search = url.query().unwrap_or("");
212
213    let line = format!("{selector}\t{search}\t{}\r\n", request.token());
214    let (header, body) = plus_exchange(&url, host, port, line.as_bytes()).await?;
215
216    if header == PlusHeader::Error {
217        return Err(ClientError::PlusError(
218            String::from_utf8_lossy(&body).trim().to_string(),
219        ));
220    }
221    Ok(PlusReply { header, body })
222}
223
224/// Fetch and parse an item's Gopher+ attribute blocks (`!`).
225pub async fn fetch_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
226    let reply = fetch_plus(url, PlusRequest::Attributes).await?;
227    Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
228}
229
230/// Fetch and parse the attribute blocks of every item in a directory (`$`).
231pub async fn fetch_directory_attributes(url: &str) -> Result<Vec<AttributeBlock>, ClientError> {
232    let reply = fetch_plus(url, PlusRequest::DirectoryAttributes).await?;
233    Ok(parse_attributes(&String::from_utf8_lossy(&reply.body)))
234}
235
236/// Send a Gopher+ request and read the reply according to its header, rather
237/// than always reading to EOF: a counted body stops at its count.
238async fn plus_exchange(
239    url: &Url,
240    host: &str,
241    port: u16,
242    request: &[u8],
243) -> Result<(PlusHeader, Vec<u8>), ClientError> {
244    if url.scheme() == "gophers" {
245        #[cfg(feature = "tls")]
246        {
247            let stream = crate::tls::connect(host, port).await?;
248            return plus_over(stream, request).await;
249        }
250        #[cfg(not(feature = "tls"))]
251        {
252            return Err(ClientError::BadUrl(
253                "gophers:// needs the `tls` feature".into(),
254            ));
255        }
256    }
257    let stream = TcpStream::connect((host, port))
258        .await
259        .map_err(|e| ClientError::Connect(format!("tcp {host}:{port}: {e}")))?;
260    plus_over(stream, request).await
261}
262
263/// The Gopher+ transaction over an already-connected stream.
264async fn plus_over<S>(mut stream: S, request: &[u8]) -> Result<(PlusHeader, Vec<u8>), ClientError>
265where
266    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
267{
268    stream
269        .write_all(request)
270        .await
271        .map_err(|e| ClientError::Io(e.to_string()))?;
272
273    let mut reader = BufReader::new(stream);
274    let mut header_line = String::new();
275    reader
276        .read_line(&mut header_line)
277        .await
278        .map_err(|e| ClientError::Io(e.to_string()))?;
279    if header_line.is_empty() {
280        return Err(ClientError::BadPlusHeader(
281            "the server closed without a header".into(),
282        ));
283    }
284    let header = parse_header(&header_line)?;
285
286    let mut body = Vec::new();
287    match header {
288        // `take` rather than a pre-sized allocation: the count comes from the
289        // server and a hostile one should not be able to ask for a huge Vec.
290        PlusHeader::Length(count) => {
291            reader
292                .take(count)
293                .read_to_end(&mut body)
294                .await
295                .map_err(|e| ClientError::Io(e.to_string()))?;
296        },
297        _ => {
298            reader
299                .read_to_end(&mut body)
300                .await
301                .map_err(|e| ClientError::Io(e.to_string()))?;
302        },
303    }
304
305    if matches!(header, PlusHeader::PeriodTerminated | PlusHeader::Error) {
306        body = strip_period_terminator(body);
307    }
308    Ok((header, body))
309}
310
311/// Remove a trailing `.` line. The newline before it belongs to the last data
312/// line, so only the terminator line itself goes.
313fn strip_period_terminator(mut body: Vec<u8>) -> Vec<u8> {
314    for terminator in [
315        b"\r\n.\r\n".as_slice(),
316        b"\n.\n".as_slice(),
317        b"\r\n.".as_slice(),
318        b"\n.".as_slice(),
319    ] {
320        if body.ends_with(terminator) {
321            let keep = if terminator.starts_with(b"\r\n") { 2 } else { 1 };
322            body.truncate(body.len() - terminator.len() + keep);
323            return body;
324        }
325    }
326    body
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn split(u: &str) -> (char, String) {
334        split_path(&Url::parse(u).unwrap())
335    }
336
337    #[test]
338    fn root_path_is_a_menu() {
339        assert_eq!(split("gopher://example.org/"), ('1', String::new()));
340        assert_eq!(split("gopher://example.org"), ('1', String::new()));
341    }
342
343    #[test]
344    fn type_and_selector_split_at_the_first_char() {
345        assert_eq!(
346            split("gopher://example.org/0/about.txt"),
347            ('0', "/about.txt".into())
348        );
349        assert_eq!(split("gopher://example.org/1/dir"), ('1', "/dir".into()));
350    }
351
352    #[test]
353    fn mime_inference() {
354        assert_eq!(mime_for_item_type('0'), "text/plain");
355        assert_eq!(mime_for_item_type('1'), "application/gopher-menu");
356        assert_eq!(mime_for_item_type('9'), "application/octet-stream");
357    }
358
359    #[tokio::test]
360    async fn a_url_without_a_host_is_refused_before_connecting() {
361        let error = fetch("gopher:///0/x").await.unwrap_err();
362        assert!(matches!(error, ClientError::BadUrl(_)), "got {error:?}");
363    }
364
365    #[test]
366    fn plus_tokens_match_the_spec() {
367        assert_eq!(PlusRequest::Item(None).token(), "+");
368        assert_eq!(
369            PlusRequest::Item(Some("text/plain".into())).token(),
370            "+text/plain"
371        );
372        assert_eq!(PlusRequest::Attributes.token(), "!");
373        assert_eq!(PlusRequest::DirectoryAttributes.token(), "$");
374    }
375
376    #[test]
377    fn the_period_terminator_goes_but_the_last_newline_stays() {
378        assert_eq!(
379            strip_period_terminator(b"one\r\ntwo\r\n.\r\n".to_vec()),
380            b"one\r\ntwo\r\n".to_vec()
381        );
382        assert_eq!(
383            strip_period_terminator(b"one\ntwo\n.\n".to_vec()),
384            b"one\ntwo\n".to_vec()
385        );
386    }
387
388    #[test]
389    fn a_body_that_merely_ends_in_a_period_is_left_alone() {
390        assert_eq!(
391            strip_period_terminator(b"see fig. 1.".to_vec()),
392            b"see fig. 1.".to_vec()
393        );
394    }
395}