feat: serve gopher with dot-stuffing and a synthetic root menu

This commit is contained in:
randogoth 2026-10-04 21:20:38 +03:00
parent 1de4674b09
commit dc2114a4d2
8 changed files with 334 additions and 11 deletions

165
bin/src/proto/gopher.rs Normal file
View file

@ -0,0 +1,165 @@
//! Gopher, RFC 1436: a selector in, a text file or a menu out.
//!
//! Only item type 0 (text) and the one menu described below. There are no
//! gophermap directory listings and no type 7 search, because neither has a
//! source in a folder of Markdown.
//!
//! Like Nex there is no status line and no redirect, so an error is a plain body
//! and a canonical target is resolved server-side rather than bounced.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use anyhow::Result;
use itsybitsy_core::site::{Resolution, Resource};
use crate::proto::for_log;
use crate::serve::Listener;
/// Gopher selectors are short; the cap is smolweb's.
const MAX_REQUEST: usize = 512;
pub fn serve(listener: &Listener, mut stream: TcpStream) -> Result<()> {
let selector = {
let mut reader = BufReader::new(stream.try_clone()?);
read_selector(&mut reader, MAX_REQUEST)
};
let Some(selector) = selector else {
log::warn!("{}: unreadable or oversized selector", listener.name);
return text(&mut stream, b"Bad request\n");
};
// Gopher carries no hostname, so the listener names its site outright.
let site = listener.only_site();
log::info!("{} gopher {}", listener.name, for_log(&selector));
// A prefix-less `gopher://host/` means item type 1 by RFC 4266, so a client
// asking for the root expects a menu rather than a text file. Serving the
// root document here would be the wrong type; a one-item menu pointing at
// `/` gets the client to the same place with the type it asked for.
if selector.is_empty() {
return menu(&mut stream, listener, &[("0", "Home", "/")]);
}
match site.resolve_flat(&selector) {
Ok(Resolution::Found(Resource::Document { page, .. })) => {
match page.body(&listener.formats[0]) {
Some(body) => text(&mut stream, body),
None => text(&mut stream, b"Internal error\n"),
}
}
Ok(Resolution::Found(Resource::Raw { path, .. })) => {
// A binary item is sent raw and ended by closing: dot-stuffing it
// would corrupt it, and a terminator would be part of the file.
let mut file = std::fs::File::open(&path)?;
std::io::copy(&mut file, &mut stream)?;
Ok(())
}
// resolve_flat follows redirects and sub-documents to real content.
Ok(_) => text(&mut stream, b"Not found\n"),
Err(err) => {
log::warn!("{} gopher {}: {err}", listener.name, for_log(&selector));
text(&mut stream, b"Internal error\n")
}
}
}
/// Read a selector: everything up to the first tab or line ending.
///
/// A tab separates a selector from a search term, which this server has no use
/// for but must not read as part of the path.
fn read_selector<R: std::io::Read>(reader: &mut BufReader<R>, cap: usize) -> Option<String> {
let mut line = Vec::new();
let mut limited = reader.take(cap as u64 + 1);
limited.read_until(b'\n', &mut line).ok()?;
if line.len() > cap {
return None;
}
let end =
line.iter().position(|byte| matches!(byte, b'\t' | b'\r' | b'\n')).unwrap_or(line.len());
String::from_utf8(line[..end].to_vec()).ok()
}
/// Send a text item: dot-stuffed, then the lone-dot terminator.
fn text(stream: &mut TcpStream, body: &[u8]) -> Result<()> {
stream.write_all(&dot_stuff(body))?;
// A line holding only a dot ends the item, which is why one in the content
// has to be doubled above.
stream.write_all(b".\r\n")?;
Ok(())
}
/// Send a menu: tab-separated items, then the terminator.
fn menu(stream: &mut TcpStream, listener: &Listener, items: &[(&str, &str, &str)]) -> Result<()> {
let mut out = Vec::new();
for (kind, display, selector) in items {
out.extend_from_slice(
format!(
"{kind}{display}\t{selector}\t{}\t{}\r\n",
listener.advertised_host, listener.advertised_port
)
.as_bytes(),
);
}
stream.write_all(&out)?;
stream.write_all(b".\r\n")?;
Ok(())
}
/// Double a leading dot on any line, so no content line can end the item.
fn dot_stuff(body: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(body.len());
let mut at_line_start = true;
for byte in body {
if at_line_start && *byte == b'.' {
out.push(b'.');
}
out.push(*byte);
at_line_start = *byte == b'\n';
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn read(input: &[u8]) -> Option<String> {
read_selector(&mut BufReader::new(input), MAX_REQUEST)
}
#[test]
fn reads_a_selector_up_to_its_terminator() {
assert_eq!(read(b"/about\r\n").as_deref(), Some("/about"));
assert_eq!(read(b"/about\n").as_deref(), Some("/about"));
assert_eq!(read(b"\r\n").as_deref(), Some(""));
}
#[test]
fn a_tab_separates_a_search_term_that_is_not_part_of_the_path() {
assert_eq!(read(b"/about\tsearch words\r\n").as_deref(), Some("/about"));
}
#[test]
fn refuses_an_oversized_selector() {
let mut long = vec![b'a'; MAX_REQUEST + 1];
long.extend_from_slice(b"\r\n");
assert_eq!(read(&long), None);
}
#[test]
fn dot_stuffing_protects_a_leading_dot_on_every_line() {
// A line holding only a dot is the terminator, so one in the content has
// to be doubled or the item would end early.
assert_eq!(dot_stuff(b".\n"), b"..\n");
assert_eq!(dot_stuff(b"a\n.b\nc\n"), b"a\n..b\nc\n".to_vec());
// A dot that is not at the start of a line is ordinary text.
assert_eq!(dot_stuff(b"a.b\n"), b"a.b\n".to_vec());
assert_eq!(dot_stuff(b"plain\n"), b"plain\n".to_vec());
}
#[test]
fn dot_stuffing_handles_a_dot_at_the_very_start() {
assert_eq!(dot_stuff(b".hidden"), b"..hidden".to_vec());
}
}

View file

@ -1,5 +1,6 @@
//! Shared plumbing for the protocol listeners.
pub mod gopher;
pub mod http;
pub mod negotiate;
pub mod nex;