Skip to main content

netget/server/gopher/
actions.rs

1//! Gopher (RFC 1436) protocol actions.
2//!
3//! The model authors the whole gopherspace. Nothing here reads a filesystem, and no action
4//! carries raw bytes: a menu is an array of structured items and a document is text, so the
5//! model never has to produce or parse wire framing. Assembling the tabs, the CRLFs, the
6//! `.\r\n` terminator and the leading-dot escaping is this module's job.
7
8use crate::llm::actions::{
9    protocol_trait::{ActionResult, Protocol, Server},
10    ActionDefinition, Parameter,
11};
12use crate::protocol::log_template::LogTemplate;
13use crate::protocol::EventType;
14use crate::state::app_state::AppState;
15use anyhow::{bail, Context, Result};
16use serde_json::json;
17use std::sync::LazyLock;
18
19/// Item types RFC 1436 defines that this server will emit.
20///
21/// Deliberately a closed set: a menu line whose first character is not a type a client
22/// recognises is silently skipped by some clients and rendered as garbage by others, and the
23/// model has no way to discover the mistake. Refusing it surfaces as an action failure the
24/// operator can see instead.
25const SUPPORTED_ITEM_TYPES: &[char] = &['0', '1', '3', '7', '9', 'g', 'I', 'h', 'i'];
26
27/// Host and port an informational (`i`) line carries by convention: it is not a link, so
28/// there is nothing to point at. `fake`/`(NULL)`/`0` is what real servers write.
29const INFO_SELECTOR: &str = "fake";
30const INFO_HOST: &str = "(NULL)";
31const INFO_PORT: u64 = 0;
32
33pub struct GopherProtocol;
34
35impl GopherProtocol {
36    pub fn new() -> Self {
37        Self
38    }
39}
40
41impl Default for GopherProtocol {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47/// Make one menu field safe to place between tabs.
48///
49/// A tab or a CR/LF inside a display string, selector or host would forge an extra field or
50/// an extra menu line — the model would be writing menu structure by accident. Every control
51/// character becomes a space, which is visible and harmless; substituting rather than deleting
52/// is deliberate, because deleting joins the two sides into one word that reads as a single
53/// legitimate value.
54///
55/// Reaching for `utils::sanitize::line_field` rather than matching `\t\r\n` alone also closes
56/// ESC, which a Gopher client prints straight to a terminal.
57fn sanitize_field(s: &str) -> String {
58    crate::utils::sanitize::line_field(s)
59}
60
61impl Protocol for GopherProtocol {
62    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
63        // Gopher is purely reactive: the server says nothing until a selector arrives.
64        Vec::new()
65    }
66
67    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
68        vec![
69            send_gopher_menu_action(),
70            send_gopher_text_action(),
71            send_gopher_error_action(),
72            close_connection_action(),
73        ]
74    }
75
76    fn protocol_name(&self) -> &'static str {
77        "Gopher"
78    }
79
80    fn get_event_types(&self) -> Vec<EventType> {
81        get_gopher_event_types()
82    }
83
84    fn stack_name(&self) -> &'static str {
85        "ETH>IP>TCP>GOPHER"
86    }
87
88    fn keywords(&self) -> Vec<&'static str> {
89        vec!["gopher"]
90    }
91
92    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
93        use crate::protocol::metadata::{
94            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
95        };
96
97        ProtocolMetadataV2::builder()
98            .state(DevelopmentState::Beta)
99            .privilege_requirement(PrivilegeRequirement::PrivilegedPort(70))
100            .well_known_port(70)
101            .implementation(
102                "Hand-rolled RFC 1436 over a plain tokio TCP loop; no library. One selector \
103                 line in, one menu or document out, then the server closes - which is what \
104                 RFC 1436 specifies and what a client reading to EOF needs.",
105            )
106            .llm_control(
107                "The entire gopherspace: menu items (type, display, selector, host, port), \
108                 document text, and type-3 error items. There is no backing store of any kind.",
109            )
110            .e2e_testing(
111                "tests/server/gopher/e2e_test.rs, 5 tests, 10 mocked LLM calls. Two tests drive the \
112                 real curl(1) binary - `curl gopher://127.0.0.1:PORT/1/…` for a menu and `/0/…` for a \
113                 document - and hard-fail (never skip) when curl is missing or lacks gopher \
114                 support. The other three use a raw TCP socket for what curl cannot show: the \
115                 type-7 tab-separated search request, the exact type-3 error bytes, and the \
116                 fail-closed reply when the model cannot be reached.",
117            )
118            .notes(
119                "curl strips the item-type character from the URL path, so \
120                 gopher://host/1/menu and gopher://host/0/menu send the identical selector \
121                 '/menu' - the type in a Gopher URL describes the expected reply, and the \
122                 server never sees it. curl passes the reply through verbatim: it neither \
123                 removes the terminating '.' line nor undoes the doubled leading dots, so \
124                 both appear in its output. Not implemented: Gopher+ (the '+' attribute \
125                 protocol), TLS ('gophers'), binary transfers, and any filesystem serving.",
126            )
127            .max_inbound_bytes(crate::server::gopher::MAX_REQUEST_BYTES)
128            .build()
129    }
130
131    fn description(&self) -> &'static str {
132        "Gopher (RFC 1436) menu and document server"
133    }
134
135    fn example_prompt(&self) -> &'static str {
136        "Gopher server on port 70 - serve a root menu with an About text file and a Files \
137         directory"
138    }
139
140    fn group_name(&self) -> &'static str {
141        "Application"
142    }
143
144    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
145        use crate::llm::actions::StartupExamples;
146        StartupExamples::new(
147            // LLM-driven
148            json!({
149                "type": "open_server",
150                "port": 70,
151                "base_stack": "gopher",
152                "instruction": "Gopher server. For the empty selector serve a root menu with an \
153                                About text file at /about.txt and a Files directory at /files. \
154                                For /about.txt serve a short document. Anything else is an error."
155            }),
156            // Script-based
157            json!({
158                "type": "open_server",
159                "port": 70,
160                "base_stack": "gopher",
161                "event_handlers": [{
162                    "event_pattern": "gopher_request",
163                    "handler": {
164                        "type": "script",
165                        "language": "python",
166                        "code": "sel = event.get('selector', '')\nif sel in ('', '/'):\n    respond([{'type': 'send_gopher_menu', 'items': [\n        {'type': 'i', 'display': 'Welcome to the hole'},\n        {'type': '0', 'display': 'About', 'selector': '/about.txt', 'host': '127.0.0.1', 'port': 70}]}])\nelif sel == '/about.txt':\n    respond([{'type': 'send_gopher_text', 'text': 'A gopher server with no disk behind it.'}])\nelse:\n    respond([{'type': 'send_gopher_error', 'message': 'No such selector: ' + sel}])"
167                    }
168                }]
169            }),
170            // Static handler
171            json!({
172                "type": "open_server",
173                "port": 70,
174                "base_stack": "gopher",
175                "event_handlers": [{
176                    "event_pattern": "gopher_request",
177                    "handler": {
178                        "type": "static",
179                        "actions": [{
180                            "type": "send_gopher_menu",
181                            "items": [
182                                {"type": "i", "display": "Welcome to the hole"},
183                                {"type": "0", "display": "About", "selector": "/about.txt",
184                                 "host": "127.0.0.1", "port": 70},
185                                {"type": "1", "display": "Files", "selector": "/files",
186                                 "host": "127.0.0.1", "port": 70}
187                            ]
188                        }]
189                    }
190                }]
191            }),
192        )
193    }
194}
195
196impl Server for GopherProtocol {
197    fn spawn(
198        &self,
199        ctx: crate::protocol::SpawnContext,
200    ) -> std::pin::Pin<
201        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
202    > {
203        Box::pin(async move {
204            use crate::server::gopher::GopherServer;
205            GopherServer::spawn_with_llm_actions(
206                ctx.legacy_listen_addr(),
207                ctx.llm_client,
208                ctx.state,
209                ctx.status_tx,
210                ctx.server_id,
211            )
212            .await
213        })
214    }
215
216    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
217        let action_type = action
218            .get("type")
219            .and_then(|v| v.as_str())
220            .context("Missing 'type' field in action")?;
221
222        match action_type {
223            "send_gopher_menu" => self.execute_send_gopher_menu(action),
224            "send_gopher_text" => self.execute_send_gopher_text(action),
225            "send_gopher_error" => self.execute_send_gopher_error(action),
226            "close_connection" => Ok(ActionResult::CloseConnection),
227            _ => Err(anyhow::anyhow!("Unknown Gopher action: {}", action_type)),
228        }
229    }
230}
231
232impl GopherProtocol {
233    /// `<type><display>\t<selector>\t<host>\t<port>\r\n` per item, then `.\r\n`.
234    fn execute_send_gopher_menu(&self, action: serde_json::Value) -> Result<ActionResult> {
235        let items = action
236            .get("items")
237            .and_then(|v| v.as_array())
238            .context("Missing 'items' parameter (must be an array of menu items)")?;
239
240        if items.is_empty() {
241            bail!("'items' must contain at least one menu item");
242        }
243
244        let mut out = String::new();
245        for (index, item) in items.iter().enumerate() {
246            let type_str = item
247                .get("type")
248                .and_then(|v| v.as_str())
249                .with_context(|| format!("Menu item {} is missing 'type'", index))?;
250
251            let mut chars = type_str.chars();
252            let item_type = match (chars.next(), chars.next()) {
253                (Some(c), None) => c,
254                _ => bail!(
255                    "Menu item {} has 'type' {:?}; a Gopher item type is exactly one character",
256                    index,
257                    type_str
258                ),
259            };
260            if !SUPPORTED_ITEM_TYPES.contains(&item_type) {
261                bail!(
262                    "Menu item {} has unsupported item type '{}'. Supported: {}",
263                    index,
264                    item_type,
265                    SUPPORTED_ITEM_TYPES
266                        .iter()
267                        .map(|c| c.to_string())
268                        .collect::<Vec<_>>()
269                        .join(", ")
270                );
271            }
272
273            let display = item
274                .get("display")
275                .and_then(|v| v.as_str())
276                .with_context(|| format!("Menu item {} is missing 'display'", index))?;
277
278            let informational = item_type == 'i';
279
280            let selector = item
281                .get("selector")
282                .and_then(|v| v.as_str())
283                .unwrap_or(if informational { INFO_SELECTOR } else { "" });
284            let host = item
285                .get("host")
286                .and_then(|v| v.as_str())
287                .unwrap_or(if informational {
288                    INFO_HOST
289                } else {
290                    "127.0.0.1"
291                });
292            let port = item
293                .get("port")
294                .and_then(|v| v.as_u64())
295                .unwrap_or(if informational { INFO_PORT } else { 70 });
296
297            out.push(item_type);
298            out.push_str(&sanitize_field(display));
299            out.push('\t');
300            out.push_str(&sanitize_field(selector));
301            out.push('\t');
302            out.push_str(&sanitize_field(host));
303            out.push('\t');
304            out.push_str(&port.to_string());
305            out.push_str("\r\n");
306        }
307
308        out.push_str(".\r\n");
309        Ok(ActionResult::Output(out.into_bytes()))
310    }
311
312    /// A document: CRLF line endings, leading dots doubled, `.\r\n` terminator.
313    ///
314    /// The doubling ("periodating") is what keeps a line that genuinely begins with a period
315    /// from ending the transfer early; RFC 1436 requires the client to undo it, and clients
316    /// that do not (curl) simply show the extra dot.
317    fn execute_send_gopher_text(&self, action: serde_json::Value) -> Result<ActionResult> {
318        let text = action
319            .get("text")
320            .and_then(|v| v.as_str())
321            .context("Missing 'text' parameter")?;
322
323        // One trailing newline is the natural way to write a document and must not become a
324        // blank final line; anything beyond that is the author's own blank line and is kept.
325        let body = text
326            .strip_suffix("\r\n")
327            .or_else(|| text.strip_suffix('\n'))
328            .unwrap_or(text);
329
330        let mut out = String::new();
331        if !body.is_empty() {
332            for line in body.split('\n') {
333                let line = line.strip_suffix('\r').unwrap_or(line);
334                if line.starts_with('.') {
335                    out.push('.');
336                }
337                out.push_str(line);
338                out.push_str("\r\n");
339            }
340        }
341        out.push_str(".\r\n");
342        Ok(ActionResult::Output(out.into_bytes()))
343    }
344
345    /// A type-3 item is the only error Gopher has. The conventional form has an empty
346    /// selector, host `error.host` and port `1`, which no client will follow.
347    fn execute_send_gopher_error(&self, action: serde_json::Value) -> Result<ActionResult> {
348        let message = action
349            .get("message")
350            .and_then(|v| v.as_str())
351            .unwrap_or("Resource not found");
352
353        let out = format!("3{}\t\terror.host\t1\r\n.\r\n", sanitize_field(message));
354        Ok(ActionResult::Output(out.into_bytes()))
355    }
356}
357
358fn send_gopher_menu_action() -> ActionDefinition {
359    ActionDefinition {
360        name: "send_gopher_menu".to_string(),
361        description: "Send a Gopher menu (a directory listing). Give structured items; the server \
362             assembles the tab-separated lines and the terminating '.' line. Item types: '0' \
363             text file, '1' directory, '3' error, '7' search, '9' binary, 'g' GIF, 'I' image, \
364             'h' HTML, 'i' informational text (not a link - selector/host/port are filled in \
365             for you). The server closes the connection after this reply, which is what RFC \
366             1436 specifies, so do not expect a follow-up request on the same connection."
367            .to_string(),
368        parameters: vec![Parameter {
369            name: "items".to_string(),
370            type_hint: "array".to_string(),
371            description: "Menu items, in display order. Each is an object: 'type' (one character, \
372                 required), 'display' (the text the user sees, required), 'selector' (what the \
373                 client sends back to fetch this item; optional, defaults to empty), 'host' \
374                 and 'port' (where to fetch it; optional, default 127.0.0.1 and 70)"
375                .to_string(),
376            required: true,
377        }],
378        example: json!({
379            "type": "send_gopher_menu",
380            "items": [
381                {"type": "i", "display": "Welcome to the gopher hole"},
382                {"type": "0", "display": "About this server", "selector": "/about.txt",
383                 "host": "127.0.0.1", "port": 70},
384                {"type": "1", "display": "Files", "selector": "/files",
385                 "host": "127.0.0.1", "port": 70},
386                {"type": "7", "display": "Search the archive", "selector": "/search",
387                 "host": "127.0.0.1", "port": 70}
388            ]
389        }),
390        log_template: Some(
391            LogTemplate::new()
392                .with_info("-> Gopher menu ({items_len} items)")
393                .with_debug("Gopher send_gopher_menu: {items_len} items")
394                .with_trace("Gopher menu: {json_pretty(items)}"),
395        ),
396    }
397}
398
399fn send_gopher_text_action() -> ActionDefinition {
400    ActionDefinition {
401        name: "send_gopher_text".to_string(),
402        description: "Send a Gopher text document (item type '0'). Give plain text with ordinary \
403             newlines; the server converts them to CRLF, escapes any line that begins with a \
404             period, and appends the terminating '.' line. The server closes the connection \
405             after this reply, which is what RFC 1436 specifies."
406            .to_string(),
407        parameters: vec![Parameter {
408            name: "text".to_string(),
409            type_hint: "string".to_string(),
410            description: "The document body, as plain text".to_string(),
411            required: true,
412        }],
413        example: json!({
414            "type": "send_gopher_text",
415            "text": "About this server\n-----------------\n\nA gopher hole with no disk behind it.\n"
416        }),
417        log_template: Some(
418            LogTemplate::new()
419                .with_info("-> Gopher document ({text_len}B)")
420                .with_debug("Gopher send_gopher_text: {text_len} bytes")
421                .with_trace("Gopher document: {preview(text, 400)}"),
422        ),
423    }
424}
425
426fn send_gopher_error_action() -> ActionDefinition {
427    ActionDefinition {
428        name: "send_gopher_error".to_string(),
429        description:
430            "Reply with a Gopher type-3 error item - the only error the protocol has. Use it \
431             for an unknown selector. The server closes the connection afterwards."
432                .to_string(),
433        parameters: vec![Parameter {
434            name: "message".to_string(),
435            type_hint: "string".to_string(),
436            description: "Error text shown to the user".to_string(),
437            required: false,
438        }],
439        example: json!({
440            "type": "send_gopher_error",
441            "message": "No such selector: /nope"
442        }),
443        log_template: Some(
444            LogTemplate::new()
445                .with_info("-> Gopher error: {message}")
446                .with_debug("Gopher send_gopher_error: {message}"),
447        ),
448    }
449}
450
451fn close_connection_action() -> ActionDefinition {
452    ActionDefinition {
453        name: "close_connection".to_string(),
454        description:
455            "Close the Gopher connection immediately, without a reply. The server already \
456             closes after every reply, so this is only needed to hang up on a request you do \
457             not want to answer at all."
458                .to_string(),
459        parameters: vec![],
460        example: json!({"type": "close_connection"}),
461        log_template: Some(
462            LogTemplate::new()
463                .with_info("Gopher connection closed")
464                .with_debug("Gopher close_connection"),
465        ),
466    }
467}
468
469/// Raised once per connection, when the client's selector line arrives.
470///
471/// `search_query` is present only for a type-7 request, where the client sends
472/// `<selector>\t<query>`. Its absence is meaningful - it distinguishes "open the search form"
473/// from "search for the empty string".
474pub static GOPHER_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
475    EventType::new(
476        "gopher_request",
477        "Client sent a Gopher selector (empty selector means the root menu)",
478        json!({
479            "type": "send_gopher_menu",
480            "items": [
481                {"type": "i", "display": "Welcome to the gopher hole"},
482                {"type": "0", "display": "About this server", "selector": "/about.txt",
483                 "host": "127.0.0.1", "port": 70},
484                {"type": "1", "display": "Files", "selector": "/files",
485                 "host": "127.0.0.1", "port": 70}
486            ]
487        }),
488    )
489    .with_parameters(vec![
490        Parameter {
491            name: "selector".to_string(),
492            type_hint: "string".to_string(),
493            description:
494                "The selector the client asked for. Empty means the root menu. Note that a \
495                 Gopher URL's item-type character is not sent by the client, so this is the \
496                 path only."
497                    .to_string(),
498            required: true,
499        },
500        Parameter {
501            name: "search_query".to_string(),
502            type_hint: "string".to_string(),
503            description:
504                "The search terms, present only when the client made a type-7 search request \
505                 (selector and query separated by a tab)"
506                    .to_string(),
507            required: false,
508        },
509    ])
510    .with_log_template(
511        LogTemplate::new()
512            .with_info("Gopher {selector}")
513            .with_debug("Gopher request: selector={selector} search_query={search_query}")
514            .with_trace("Gopher: {json_pretty(.)}"),
515    )
516    .with_actions(vec![
517        send_gopher_menu_action(),
518        send_gopher_text_action(),
519        send_gopher_error_action(),
520        close_connection_action(),
521    ])
522    .with_alternative_example(json!({
523        "type": "send_gopher_text",
524        "text": "About this server\n-----------------\n\nA gopher hole with no disk behind it.\n"
525    }))
526});
527
528pub fn get_gopher_event_types() -> Vec<EventType> {
529    vec![GOPHER_REQUEST_EVENT.clone()]
530}