fix: serve a one-item menu at the Gopher root, not raw prose

RFC 4266 defines a bare gopher://host/ (no item-type prefix in the URL)
as defaulting to type '1' -- a menu -- so every RFC-following client,
Lagrange included, parses whatever the empty selector returns as
tab-delimited menu lines rather than displaying it as text. Handing back
the homepage's own rendered prose there has no tabs in it, so it parses
as zero valid entries and renders blank.

Only the truly empty selector gets the synthetic menu; its one item
points at the homepage under "/", a distinguishable non-empty selector
that still resolves to real content exactly as before. This still isn't
directory browsing -- there's still no listing of the content tree, just
the one link needed to make the root itself navigable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
randogoth 2026-09-26 15:30:09 +03:00
parent 0b67069760
commit 5e80e84041
3 changed files with 97 additions and 3 deletions

View file

@ -77,7 +77,7 @@ def main(argv=None) -> int:
if args.gopher: if args.gopher:
gopher_addr = _parse_address(args.gopher) gopher_addr = _parse_address(args.gopher)
threads.append(_serve_in_thread(GopherServer(gopher_addr, site), "gopher", gopher_addr)) threads.append(_serve_in_thread(GopherServer(gopher_addr, site, args.host), "gopher", gopher_addr))
if not threads: if not threads:
sys.stderr.write("smolweb: every listener disabled, nothing to do\n") sys.stderr.write("smolweb: every listener disabled, nothing to do\n")

View file

@ -7,6 +7,17 @@ folder has no use for); the response for a text document is dot-stuffed
and terminated with a lone "." line per RFC 1436, then the connection and terminated with a lone "." line per RFC 1436, then the connection
closes. Raw files are streamed as-is with no terminator, matching how closes. Raw files are streamed as-is with no terminator, matching how
binary Gopher items are conventionally served. binary Gopher items are conventionally served.
One exception to "no gophermaps": a bare `gopher://host/` with no
item-type prefix is defined by RFC 4266 to default to type '1' (a menu),
and real clients (Lagrange included) parse whatever comes back from an
empty selector as tab-delimited menu lines, not prose -- serving the
homepage's own text there produces zero valid menu entries and an empty
page. So the empty selector alone gets a one-item synthetic menu pointing
at the homepage under a distinguishable non-empty selector ("/"); every
other selector, including that one, is served as real content exactly as
before. This is not directory browsing -- there is still no listing of
the content tree -- just the one link needed to make the root navigable.
""" """
from __future__ import annotations from __future__ import annotations
@ -51,7 +62,9 @@ class GopherHandler(socketserver.StreamRequestHandler):
self._send_text(b"Internal error.\n") self._send_text(b"Internal error.\n")
return return
if isinstance(resource, RawFile): if not selector and not isinstance(resource, RawFile):
self._send_menu([("0", "Home", "/", self.server.host, self.server.port)])
elif isinstance(resource, RawFile):
self._send_raw(resource.data) self._send_raw(resource.data)
else: else:
self._send_text(resource.gopher) self._send_text(resource.gopher)
@ -71,11 +84,24 @@ class GopherHandler(socketserver.StreamRequestHandler):
except OSError: except OSError:
pass pass
def _send_menu(self, items) -> None:
lines = [
f"{item_type}{display}\t{item_selector}\t{host}\t{port}\r\n"
for item_type, display, item_selector, host, port in items
]
try:
self.wfile.write("".join(lines).encode("utf-8"))
self.wfile.write(b".\r\n")
except OSError:
pass
class GopherServer(socketserver.ThreadingTCPServer): class GopherServer(socketserver.ThreadingTCPServer):
daemon_threads = True daemon_threads = True
allow_reuse_address = True allow_reuse_address = True
def __init__(self, address, site: Site) -> None: def __init__(self, address, site: Site, host: str) -> None:
self.site = site self.site = site
self.host = host
self.port = address[1]
super().__init__(address, GopherHandler) super().__init__(address, GopherHandler)

68
tests/test_gopher.py Normal file
View file

@ -0,0 +1,68 @@
"""Gopher listener: the RFC 4266 root-menu compatibility fix, dot-stuffing."""
from __future__ import annotations
import socket
import threading
import pytest
from smolweb.servers.gopher import GopherServer
from smolweb.site import Site
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
@pytest.fixture
def gopher_server(site: Site):
port = _free_port()
server = GopherServer(("127.0.0.1", port), site, "localhost")
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield server
server.shutdown()
server.server_close()
def _request(server: GopherServer, selector: str) -> bytes:
with socket.create_connection(("127.0.0.1", server.port), timeout=5) as sock:
sock.sendall(f"{selector}\r\n".encode("utf-8"))
chunks = []
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
class TestGopherRootMenu:
def test_empty_selector_returns_a_one_item_menu(self, gopher_server: GopherServer):
# A bare gopher://host/ has no item-type prefix, which RFC 4266
# defines as defaulting to type '1' (menu) -- real clients parse
# the response as tab-delimited menu lines, not prose, so the
# homepage's own rendered text must never be sent back here.
response = _request(gopher_server, "")
assert response == f"0Home\t/\tlocalhost\t{gopher_server.port}\r\n.\r\n".encode("utf-8")
def test_menu_link_selector_resolves_to_real_content(self, gopher_server: GopherServer):
# The menu's own selector ("/") must not trigger the same
# menu-synthesis branch -- only the truly empty selector does.
response = _request(gopher_server, "/")
assert response.endswith(b".\r\n")
assert b"Hello." in response
assert not response.startswith(b"0Home\t")
def test_non_root_selector_is_unaffected(self, gopher_server: GopherServer):
response = _request(gopher_server, "about")
assert b"Body." in response
assert not response.startswith(b"0Home\t")
class TestGopherDotStuffing:
def test_not_found_response_is_well_formed(self, gopher_server: GopherServer):
response = _request(gopher_server, "nope")
assert response == b"Not found.\n.\r\n"