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

View file

@ -27,6 +27,8 @@ impl Server {
write(dir.path(), "one/about.md", "# About\n\nAbout the first.\n");
write(dir.path(), "one/img.png", "not really a png");
write(dir.path(), "one/.itsybitsy.toml", "[defaults]\nmax_age = 120\n");
// A line starting with a dot, which Gopher has to double.
write(dir.path(), "one/dotted.md", "# Dotted\n\n .hidden\n");
write(dir.path(), "two/index.md", "# Two\n\nSecond site.\n");
write(
dir.path(),
@ -69,6 +71,12 @@ protocol = "nex"
bind = "127.0.0.1:0"
formats = ["gemtext"]
site = "two"
[listener.gopher]
protocol = "gopher"
bind = "127.0.0.1:0"
formats = ["gemtext"]
site = "one"
"#,
one = dir.path().join("one").to_str().unwrap(),
two = dir.path().join("two").to_str().unwrap(),
@ -408,6 +416,95 @@ fn nothing_outside_the_root_is_reachable_over_any_protocol() {
assert_eq!(server.send("nex", b"/.itsybitsy.toml\r\n"), "Not found\n");
}
// -- Gopher --------------------------------------------------------------
//
// Ported from smolweb's tests/test_gopher.py, where the exact wire bytes are the
// assertion: Gopher has no status line, so framing is all a client has.
#[test]
fn an_empty_selector_gets_a_one_item_menu() {
// RFC 4266: a prefix-less `gopher://host/` means item type 1, so a client
// asking for the root expects a menu rather than a text file.
let server = Server::start();
let port = server.addr("gopher").port();
let response = server.send("gopher", b"\r\n");
assert_eq!(response, format!("0Home\t/\tone.test\t{port}\r\n.\r\n"));
}
#[test]
fn the_root_selector_serves_the_document_as_text() {
let server = Server::start();
let response = server.send("gopher", b"/\r\n");
assert!(response.starts_with("# One"), "{response:?}");
assert!(response.ends_with(".\r\n"), "{response:?}");
}
#[test]
fn a_text_item_ends_with_the_lone_dot_terminator() {
let server = Server::start();
let response = server.send("gopher", b"/about\r\n");
assert!(response.contains("About the first."), "{response:?}");
assert!(response.ends_with("\n.\r\n"), "{response:?}");
}
#[test]
fn a_missing_selector_is_a_well_formed_text_item() {
// No status to report with, so the error is the item's content. smolweb says
// "Not found." here and "Not found" over HTTP and Nex; one wording is used
// across every protocol instead.
let server = Server::start();
assert_eq!(server.send("gopher", b"/nope\r\n"), "Not found\n.\r\n");
}
#[test]
fn a_selector_without_a_leading_slash_still_resolves() {
// Clients built from a menu line send the selector verbatim, and a menu may
// carry it either way.
let server = Server::start();
assert!(server.send("gopher", b"about\r\n").contains("About the first."));
}
#[test]
fn a_tab_separated_search_term_is_not_part_of_the_selector() {
let server = Server::start();
let response = server.send("gopher", b"/about\tignored words\r\n");
assert!(response.contains("About the first."), "{response:?}");
}
#[test]
fn gopher_resolves_a_redirect_server_side() {
// There is no redirect item type to bounce with.
let server = Server::start();
let response = server.send("gopher", b"/about.md\r\n");
assert!(response.contains("About the first."), "{response:?}");
}
#[test]
fn a_binary_item_is_sent_raw_with_no_terminator() {
// Dot-stuffing it would corrupt it, and a terminator would become part of
// the file.
let server = Server::start();
let response = server.send("gopher", b"/img.png\r\n");
assert_eq!(response, "not really a png");
}
#[test]
fn a_leading_dot_in_the_content_is_doubled() {
let server = Server::start();
let response = server.send("gopher", b"/dotted\r\n");
// The source line is `.hidden`; undoubled it would end the item early.
assert!(response.contains("\n..hidden"), "{response:?}");
assert!(response.ends_with(".\r\n"), "{response:?}");
}
#[test]
fn an_oversized_selector_is_refused() {
let server = Server::start();
let mut request = vec![b'a'; 600];
request.extend_from_slice(b"\r\n");
assert_eq!(server.send("gopher", &request), "Bad request\n.\r\n");
}
// -- Sub-documents -------------------------------------------------------
/// A page that paginates addresses sub-documents under its own URL. Only a