diff --git a/src/smolweb/cli.py b/src/smolweb/cli.py index d7aff01..d60c1d8 100644 --- a/src/smolweb/cli.py +++ b/src/smolweb/cli.py @@ -77,7 +77,7 @@ def main(argv=None) -> int: if 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: sys.stderr.write("smolweb: every listener disabled, nothing to do\n") diff --git a/src/smolweb/servers/gopher.py b/src/smolweb/servers/gopher.py index 0bae210..0f32912 100644 --- a/src/smolweb/servers/gopher.py +++ b/src/smolweb/servers/gopher.py @@ -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 closes. Raw files are streamed as-is with no terminator, matching how 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 @@ -51,7 +62,9 @@ class GopherHandler(socketserver.StreamRequestHandler): self._send_text(b"Internal error.\n") 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) else: self._send_text(resource.gopher) @@ -71,11 +84,24 @@ class GopherHandler(socketserver.StreamRequestHandler): except OSError: 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): daemon_threads = 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.host = host + self.port = address[1] super().__init__(address, GopherHandler) diff --git a/tests/test_gopher.py b/tests/test_gopher.py new file mode 100644 index 0000000..798f805 --- /dev/null +++ b/tests/test_gopher.py @@ -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"