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