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:
parent
0b67069760
commit
5e80e84041
3 changed files with 97 additions and 3 deletions
68
tests/test_gopher.py
Normal file
68
tests/test_gopher.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue