Skip to main content

gopher_protocol/
plus.rs

1//! Gopher+ : the 1993 upward-compatible enhancements to RFC 1436.
2//!
3//! Gopher+ adds four things to gopher, and this module models three of them
4//! (the fourth, the item-line marker, belongs to [`crate::menu`] because it
5//! rides in the menu):
6//!
7//! - a **response header**, so a reply can declare its length instead of
8//!   relying on the server closing the connection ([`PlusHeader`]);
9//! - **attribute blocks**, metadata about an item retrieved separately from
10//!   the item itself ([`AttributeBlock`], [`View`]);
11//! - **ASK forms**, the interactive questionnaire a `?` item carries
12//!   ([`AskDirective`]).
13//!
14//! Everything here is pure parsing with no dependencies, so it is available
15//! under `default-features = false`.
16//!
17//! Gopher+ was never an RFC. The reference is "Gopher+: Upward compatible
18//! enhancements to the Internet Gopher protocol" (University of Minnesota,
19//! 1993).
20
21// ── Response header ────────────────────────────────────────────────────────
22
23/// The first line of a Gopher+ reply.
24///
25/// The first character is `+` for success or `-` for failure, followed by a
26/// decimal token: a byte count, `-1` for a period-terminated body, or `-2` for
27/// a body that ends when the connection closes.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum PlusHeader {
30    /// `+<count>`: the body is exactly this many bytes.
31    Length(u64),
32    /// `+-1`: read until a `.` on a line of its own.
33    PeriodTerminated,
34    /// `+-2`: read until the connection closes. Binary-safe, since no
35    /// terminator can collide with the payload.
36    UntilClose,
37    /// `--1`: the request failed. The body carries the error text and is
38    /// period-terminated.
39    Error,
40}
41
42/// What [`parse_header`] could not make sense of.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct MalformedHeader(pub String);
45
46impl std::fmt::Display for MalformedHeader {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(f, "malformed gopher+ header: {}", self.0)
49    }
50}
51
52impl std::error::Error for MalformedHeader {}
53
54/// Parse a Gopher+ response header line (without its CRLF).
55///
56/// ```
57/// use gopher_protocol::plus::{PlusHeader, parse_header};
58///
59/// assert_eq!(parse_header("+5340"), Ok(PlusHeader::Length(5340)));
60/// assert_eq!(parse_header("+-1"), Ok(PlusHeader::PeriodTerminated));
61/// assert_eq!(parse_header("+-2"), Ok(PlusHeader::UntilClose));
62/// assert_eq!(parse_header("--1"), Ok(PlusHeader::Error));
63/// assert!(parse_header("5340").is_err(), "a header must carry + or -");
64/// ```
65pub fn parse_header(line: &str) -> Result<PlusHeader, MalformedHeader> {
66    let line = line.trim_end_matches(['\r', '\n']);
67    let (sign, token) = line
68        .split_at_checked(1)
69        .ok_or_else(|| MalformedHeader(line.to_string()))?;
70    // The token may be followed by whitespace and further text; the number is
71    // all that is defined.
72    let token = token.split_whitespace().next().unwrap_or("");
73    match (sign, token) {
74        ("-", _) => Ok(PlusHeader::Error),
75        ("+", "-1") => Ok(PlusHeader::PeriodTerminated),
76        ("+", "-2") => Ok(PlusHeader::UntilClose),
77        ("+", count) => count
78            .parse::<u64>()
79            .map(PlusHeader::Length)
80            .map_err(|_| MalformedHeader(line.to_string())),
81        _ => Err(MalformedHeader(line.to_string())),
82    }
83}
84
85/// What a Gopher+ retrieval asks for.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub enum PlusRequest {
88    /// `+`: the item itself. The optional representation names one of the
89    /// alternates the item's `+VIEWS` block advertises.
90    Item(Option<String>),
91    /// `!`: this item's attribute blocks.
92    Attributes,
93    /// `$`: the attribute blocks of every item in a directory.
94    DirectoryAttributes,
95}
96
97impl PlusRequest {
98    /// The token appended after the request's second TAB.
99    pub fn token(&self) -> String {
100        match self {
101            Self::Item(None) => "+".to_string(),
102            Self::Item(Some(view)) => format!("+{view}"),
103            Self::Attributes => "!".to_string(),
104            Self::DirectoryAttributes => "$".to_string(),
105        }
106    }
107
108    /// Read a token back, which is what a server does with the third field.
109    ///
110    /// `$` may carry a filter of requested block names (`$+VIEWS+ABSTRACT`);
111    /// that filter is advisory, so it is accepted and not modelled.
112    pub fn from_token(token: &str) -> Option<Self> {
113        let token = token.trim_end_matches(['\r', '\n']);
114        match token.chars().next()? {
115            '+' => Some(Self::Item({
116                let view = &token[1..];
117                (!view.is_empty()).then(|| view.to_string())
118            })),
119            '!' => Some(Self::Attributes),
120            '$' => Some(Self::DirectoryAttributes),
121            _ => None,
122        }
123    }
124}
125
126// ── Attribute blocks ───────────────────────────────────────────────────────
127
128/// One Gopher+ attribute block.
129///
130/// A block opens with `+` in column one followed by its name and a colon;
131/// every line belonging to it begins with a space. A server must return
132/// `+INFO` for every item it lists.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct AttributeBlock {
135    /// The block name without its leading `+`, e.g. `INFO`, `ADMIN`, `VIEWS`.
136    pub name: String,
137    /// Text on the block's own line after the colon. `+INFO` carries the
138    /// item's gopher descriptor here; most other blocks leave it empty.
139    pub head: Option<String>,
140    /// The block's continuation lines, each with its leading space removed.
141    pub lines: Vec<String>,
142}
143
144impl AttributeBlock {
145    /// The block's continuation lines as `name: value` pairs, for the blocks
146    /// built that way (`+ADMIN`, `+VIEWS`). Lines without a colon are skipped.
147    pub fn pairs(&self) -> Vec<(&str, &str)> {
148        self.lines
149            .iter()
150            .filter_map(|line| line.split_once(':'))
151            .map(|(k, v)| (k.trim(), v.trim()))
152            .collect()
153    }
154}
155
156/// Parse a Gopher+ attribute response into its blocks, in order.
157///
158/// Text before the first block is ignored, and a block with no continuation
159/// lines is still a block.
160pub fn parse_attributes(text: &str) -> Vec<AttributeBlock> {
161    let mut blocks: Vec<AttributeBlock> = Vec::new();
162    for line in text.lines() {
163        if line == "." {
164            break;
165        }
166        if let Some(rest) = line.strip_prefix('+') {
167            // A block name runs to the colon and cannot itself contain `+`.
168            let (name, head) = match rest.split_once(':') {
169                Some((name, head)) => (name, head.trim()),
170                None => (rest, ""),
171            };
172            blocks.push(AttributeBlock {
173                name: name.trim().to_string(),
174                head: (!head.is_empty()).then(|| head.to_string()),
175                lines: Vec::new(),
176            });
177        } else if let Some(content) = line.strip_prefix(' ') {
178            if let Some(block) = blocks.last_mut() {
179                block.lines.push(content.to_string());
180            }
181        }
182    }
183    blocks
184}
185
186/// One entry of a `+VIEWS` block: an alternate representation of an item.
187#[derive(Clone, Debug, PartialEq, Eq)]
188pub struct View {
189    /// The representation's MIME type, e.g. `text/plain`.
190    pub mime: String,
191    /// The RFC 1766 language tag when the view names one, e.g. `De_DE`.
192    pub language: Option<String>,
193    /// The size as the server wrote it, e.g. `<10k>`. Kept verbatim because
194    /// the spec's sizes are explicitly approximate.
195    pub size: Option<String>,
196}
197
198/// Parse a `+VIEWS` block into its alternate representations.
199///
200/// ```
201/// use gopher_protocol::plus::{parse_attributes, parse_views};
202///
203/// let blocks = parse_attributes("+VIEWS:\n Text/plain: <10k>\n Text/plain De_DE: <15k>\n");
204/// let views = parse_views(&blocks[0]);
205///
206/// assert_eq!(views[0].mime, "Text/plain");
207/// assert_eq!(views[0].language, None);
208/// assert_eq!(views[1].language.as_deref(), Some("De_DE"));
209/// assert_eq!(views[1].size.as_deref(), Some("<15k>"));
210/// ```
211pub fn parse_views(block: &AttributeBlock) -> Vec<View> {
212    block
213        .lines
214        .iter()
215        .filter_map(|line| {
216            let (label, size) = match line.split_once(':') {
217                Some((label, size)) => (label.trim(), size.trim()),
218                None => (line.trim(), ""),
219            };
220            if label.is_empty() {
221                return None;
222            }
223            // `Text/plain De_DE` is a MIME type and a language tag.
224            let (mime, language) = match label.split_once(char::is_whitespace) {
225                Some((mime, lang)) => (mime.trim(), Some(lang.trim().to_string())),
226                None => (label, None),
227            };
228            Some(View {
229                mime: mime.to_string(),
230                language,
231                size: (!size.is_empty()).then(|| size.to_string()),
232            })
233        })
234        .collect()
235}
236
237// ── ASK forms ──────────────────────────────────────────────────────────────
238
239/// One line of an `+ASK` block: a question to put to the user.
240///
241/// The client presents these in the order they appear and sends the answers
242/// back in the same order.
243#[derive(Clone, Debug, PartialEq, Eq)]
244pub enum AskDirective {
245    /// `Ask:` a single-line answer, with an optional default.
246    Ask {
247        prompt: String,
248        default: Option<String>,
249    },
250    /// `AskP:` the same, but the answer is masked as it is typed.
251    AskPassword {
252        prompt: String,
253        default: Option<String>,
254    },
255    /// `AskL:` a multi-line answer.
256    AskLong {
257        prompt: String,
258        default: Option<String>,
259    },
260    /// `AskF:` a local filename to store something under.
261    AskFile {
262        prompt: String,
263        default: Option<String>,
264    },
265    /// `Select:` several options, any number of which may be chosen.
266    Select {
267        prompt: String,
268        options: Vec<String>,
269    },
270    /// `Choose:` several options, exactly one of which may be chosen.
271    Choose {
272        prompt: String,
273        options: Vec<String>,
274    },
275    /// `ChooseF:` pick an existing local file.
276    ChooseFile { prompt: String },
277    /// `Note:` text to show, which asks nothing.
278    Note(String),
279    /// A directive this crate does not model, kept verbatim so a client can
280    /// show it rather than silently dropping a question.
281    Unknown { directive: String, rest: String },
282}
283
284/// Parse an `+ASK` block into its directives, in order.
285///
286/// ```
287/// use gopher_protocol::plus::{AskDirective, parse_attributes, parse_ask};
288///
289/// let blocks = parse_attributes("+ASK:\n Ask: How many volts?\n Choose: Deliver shock?\tYes\tNo\n");
290/// let form = parse_ask(&blocks[0]);
291///
292/// assert!(matches!(&form[0], AskDirective::Ask { prompt, .. } if prompt == "How many volts?"));
293/// match &form[1] {
294///     AskDirective::Choose { options, .. } => assert_eq!(options, &["Yes", "No"]),
295///     other => panic!("expected a Choose, got {other:?}"),
296/// }
297/// ```
298pub fn parse_ask(block: &AttributeBlock) -> Vec<AskDirective> {
299    block.lines.iter().map(|line| parse_ask_line(line)).collect()
300}
301
302fn parse_ask_line(line: &str) -> AskDirective {
303    let (directive, rest) = match line.split_once(':') {
304        Some((directive, rest)) => (directive.trim(), rest.trim_start()),
305        None => (line.trim(), ""),
306    };
307
308    // A prompt and its tab-separated tail: a default for the Ask family, the
309    // option list for Select and Choose.
310    let mut fields = rest.split('\t');
311    let prompt = fields.next().unwrap_or("").trim().to_string();
312    let tail: Vec<String> = fields
313        .map(|f| f.trim().to_string())
314        .filter(|f| !f.is_empty())
315        .collect();
316    let default = tail.first().cloned();
317
318    match directive {
319        "Ask" => AskDirective::Ask { prompt, default },
320        "AskP" => AskDirective::AskPassword { prompt, default },
321        "AskL" => AskDirective::AskLong { prompt, default },
322        "AskF" => AskDirective::AskFile { prompt, default },
323        "Select" => AskDirective::Select {
324            prompt,
325            options: tail,
326        },
327        "Choose" => AskDirective::Choose {
328            prompt,
329            options: tail,
330        },
331        "ChooseF" => AskDirective::ChooseFile { prompt },
332        "Note" => AskDirective::Note(rest.trim().to_string()),
333        other => AskDirective::Unknown {
334            directive: other.to_string(),
335            rest: rest.to_string(),
336        },
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    const SAMPLE: &str = "+INFO: 0Some file or other\tmoo selector\thost2\tport2\t+\n\
345                          +ADMIN:\n\
346                          \x20Admin: Frodo Gophermeister <fng@bogus.edu>\n\
347                          \x20Mod-Date: Wed Jul 28 17:02:01 1993 <19930728170201>\n\
348                          +VIEWS:\n\
349                          \x20Text/plain: <10k>\n\
350                          \x20application/postscript: <100k>\n";
351
352    #[test]
353    fn header_forms() {
354        assert_eq!(parse_header("+5340"), Ok(PlusHeader::Length(5340)));
355        assert_eq!(parse_header("+-1"), Ok(PlusHeader::PeriodTerminated));
356        assert_eq!(parse_header("+-2"), Ok(PlusHeader::UntilClose));
357        assert_eq!(parse_header("--1"), Ok(PlusHeader::Error));
358    }
359
360    #[test]
361    fn a_header_without_a_sign_is_malformed() {
362        assert!(parse_header("5340").is_err());
363        assert!(parse_header("").is_err());
364        assert!(parse_header("+banana").is_err());
365    }
366
367    #[test]
368    fn a_trailing_crlf_does_not_defeat_the_count() {
369        assert_eq!(parse_header("+5340\r\n"), Ok(PlusHeader::Length(5340)));
370    }
371
372    #[test]
373    fn blocks_split_on_the_leading_plus() {
374        let blocks = parse_attributes(SAMPLE);
375        assert_eq!(
376            blocks.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
377            vec!["INFO", "ADMIN", "VIEWS"]
378        );
379    }
380
381    #[test]
382    fn info_carries_its_descriptor_on_the_block_line() {
383        let blocks = parse_attributes(SAMPLE);
384        assert!(blocks[0].head.as_deref().unwrap().starts_with("0Some file"));
385        assert!(blocks[0].lines.is_empty(), "INFO is a one-line block");
386    }
387
388    #[test]
389    fn continuation_lines_lose_their_leading_space_and_pair_up() {
390        let blocks = parse_attributes(SAMPLE);
391        let admin = &blocks[1];
392        assert_eq!(admin.lines.len(), 2);
393        assert_eq!(
394            admin.pairs()[0],
395            ("Admin", "Frodo Gophermeister <fng@bogus.edu>")
396        );
397    }
398
399    #[test]
400    fn views_split_mime_language_and_size() {
401        let blocks = parse_attributes(SAMPLE);
402        let views = parse_views(&blocks[2]);
403        assert_eq!(views.len(), 2);
404        assert_eq!(views[0].mime, "Text/plain");
405        assert_eq!(views[0].size.as_deref(), Some("<10k>"));
406        assert_eq!(views[1].mime, "application/postscript");
407    }
408
409    #[test]
410    fn a_language_tag_is_split_off_the_mime_type() {
411        let blocks = parse_attributes("+VIEWS:\n Text/plain De_DE: <15k>\n");
412        let views = parse_views(&blocks[0]);
413        assert_eq!(views[0].mime, "Text/plain");
414        assert_eq!(views[0].language.as_deref(), Some("De_DE"));
415    }
416
417    #[test]
418    fn ask_directives_keep_their_order_and_kind() {
419        let blocks = parse_attributes(
420            "+ASK:\n\
421             \x20Ask: How many volts?\tdefault volts\n\
422             \x20AskP: Password?\n\
423             \x20Choose: Deliver shock?\tYes\tNo\n\
424             \x20Note: be careful\n",
425        );
426        let form = parse_ask(&blocks[0]);
427
428        assert_eq!(
429            form[0],
430            AskDirective::Ask {
431                prompt: "How many volts?".into(),
432                default: Some("default volts".into()),
433            }
434        );
435        assert_eq!(
436            form[1],
437            AskDirective::AskPassword {
438                prompt: "Password?".into(),
439                default: None,
440            }
441        );
442        assert_eq!(
443            form[2],
444            AskDirective::Choose {
445                prompt: "Deliver shock?".into(),
446                options: vec!["Yes".into(), "No".into()],
447            }
448        );
449        assert_eq!(form[3], AskDirective::Note("be careful".into()));
450    }
451
452    #[test]
453    fn an_unmodelled_directive_survives_rather_than_vanishing() {
454        let blocks = parse_attributes("+ASK:\n Wibble: something\n");
455        let form = parse_ask(&blocks[0]);
456        assert_eq!(
457            form[0],
458            AskDirective::Unknown {
459                directive: "Wibble".into(),
460                rest: "something".into(),
461            }
462        );
463    }
464
465    #[test]
466    fn a_period_terminator_ends_the_attribute_stream() {
467        let blocks = parse_attributes("+INFO: 1x\n.\n+ADMIN:\n Admin: nobody\n");
468        assert_eq!(blocks.len(), 1);
469    }
470}