feat: serve gopher with dot-stuffing and a synthetic root menu
This commit is contained in:
parent
1de4674b09
commit
dc2114a4d2
8 changed files with 334 additions and 11 deletions
165
bin/src/proto/gopher.rs
Normal file
165
bin/src/proto/gopher.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
//! Shared plumbing for the protocol listeners.
|
||||
|
||||
pub mod gopher;
|
||||
pub mod http;
|
||||
pub mod negotiate;
|
||||
pub mod nex;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue