1use 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
19const SUPPORTED_ITEM_TYPES: &[char] = &['0', '1', '3', '7', '9', 'g', 'I', 'h', 'i'];
26
27const 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
47fn 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 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 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 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 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 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 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 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 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
469pub 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}