Keyboard-driven browser for the small web, with native Gopher and Gemini
feat: own visual identity, and gemini over a shared small web layer
F7, the visual identity. The shared chrome stylesheet is now a set of
CSS custom properties on :root, with a light variant behind
prefers-color-scheme, and the nine internal pages draw their colours
from those tokens instead of hardcoding them. The GTK theme in
theme.lua is rebuilt around the same phosphor palette, private tabs
read as their own mode rather than a darker shade, and the new tab page
is an authored plate with the mark, the name and the keys worth
knowing, its colours taken from the theme.
F8, the small web. gopher and gemini now share lib/smallweb: one page
shell, one stylesheet, and a render mode setting that applies to both.
Terminal mode is full width and monospaced with a caret on every link;
page mode reads like a document. gopher was refactored onto it, so the
two schemes cannot drift.
gemini:// is new. It speaks the protocol directly over TLS through
LuaSec, renders gemtext to HTML, follows input and redirect status
codes, and escapes every field so a hostile page cannot become markup.
Trust is trust on first use, in lib/smallweb/tofu: the first
certificate a host presents is remembered and a change is reported, not
accepted, with the choice to trust the new one. Without LuaSec the
scheme still loads and every page says what to install.
skull://smallweb (gS) switches the render mode and lists the remembered
gemini hosts, forgetting any of them. Verified end to end against
geminiprotocol.net: the handshake completes and the fingerprint is
stored.
Tests cover gemtext rendering including markup escaping, url parsing
and link resolution, and the full tofu lifecycle. 97 passing.
@@ -1,8 +1,9 @@ # Skull Browser
A keyboard-driven web browser that treats the small web as a first-class
-citizen. Regular sites over `https://`, plus native `gopher://` support.
-Everything above the C core is Lua, and you can change all of it.
+citizen. Regular sites over `https://`, plus native `gopher://` and
+`gemini://` support. Everything above the C core is Lua, and you can change
+all of it.


@@ -28,7 +29,8 @@ GTK 3 WebKitGTK 4.1
Lua 5.1 or LuaJIT
lfs (lua filesystem)
- socket (lua socket, for gopher)
+ socket (lua socket, for gopher and gemini)
+ ssl (luasec, for gemini TLS)
sqlite3
gstreamer (video playback)
@@ -88,7 +90,31 @@ so copy it by hand as shown above. Themes are a flat Lua table in `theme.lua` with a cascading naming scheme:
`tab_selected_fg` falls back to `selected_fg`, then to `fg`. Change a handful
-of root keys and the whole interface follows.
+of root keys and the whole interface follows. The internal `skull://` pages
+read their colours from the same file through CSS custom properties, so one
+edit moves the whole browser.
+
+
+## Small web
+
+`gopher://` and `gemini://` are rendered by the browser itself, not proxied.
+Both share one presentation layer, so a setting applies to both.
+
+ skull gopher://gopher.floodgap.com
+ skull gemini://geminiprotocol.net
+
+Two render modes, switched at `skull://smallweb/` or with `gS`:
+
+ terminal full width, monospaced, a caret on every link (default)
+ page a comfortable measure, read like a document
+
+Gemini has no certificate authorities. The first time a host is seen its
+certificate is remembered, and a different one later is reported rather than
+accepted. This is trust on first use. `skull://smallweb/` lists the remembered
+hosts and forgets any of them, which trusts the next certificate afresh.
+
+Gemini needs LuaSec (the `ssl` module). Without it the scheme still loads but
+every page explains what to install. `setup-deps.sh` installs it.
## Tests
@@ -0,0 +1,507 @@+--- Add gemini:// scheme support.
+--
+-- Gemini is a small protocol: open TLS, send one line holding the URL, read
+-- back one status line and a body. There is no compression, no cookies, no
+-- scripting and no certificate authorities.
+--
+-- Trust works by trust on first use, handled by `smallweb.tofu`: the
+-- certificate a host presents the first time is remembered, and a different
+-- one later is reported rather than accepted.
+--
+-- Needs LuaSec, the `ssl` module. Without it the scheme is registered anyway
+-- and every request explains what is missing, rather than failing silently.
+--
+-- @module gemini
+-- @copyright 2026 Pablo Murad <pablomurad@pm.me>
+
+local smallweb = require("smallweb")
+local tofu = require("smallweb.tofu")
+
+local socket_loaded, socket = pcall(require, "socket")
+local ssl_loaded, ssl = pcall(require, "ssl")
+
+local error_page = require("error_page")
+local webview = require("webview")
+
+local _M = {}
+
+luakit.register_scheme("gemini")
+
+--- Default port for gemini, fixed by the specification.
+-- @type number
+-- @readwrite
+_M.default_port = 1965
+
+--- How many redirects to follow before giving up.
+-- @type number
+-- @readwrite
+_M.max_redirects = 5
+
+--- How long to wait on a host, in seconds.
+-- @type number
+-- @readwrite
+_M.timeout = 30
+
+-- ---------------------------------------------------------------------------
+-- URL handling
+-- ---------------------------------------------------------------------------
+
+--- Parse a gemini URL.
+-- @tparam string url The URL to parse.
+-- @treturn table Fields `host`, `port`, `path`, `query` and `title`.
+_M.parse_url = function (url)
+ assert(type(url) == "string", "expected a url string")
+
+ local rest = url:match("^[Gg][Ee][Mm][Ii][Nn][Ii]://(.*)$")
+ if not rest then error("not a gemini url: " .. url, 0) end
+
+ local authority, path = rest:match("^([^/]*)(.*)$")
+ if not authority or authority == "" then
+ error("gemini url has no host: " .. url, 0)
+ end
+
+ local query
+ path, query = (path or ""):match("^([^?]*)%??(.*)$")
+ if path == "" then path = "/" end
+
+ local host, port = authority:match("^(.-):(%d+)$")
+ host = host or authority
+ port = tonumber(port) or _M.default_port
+
+ -- The host reaches a TLS handshake and a fingerprint store, so keep it to
+ -- what a host name can actually contain.
+ host = host:gsub("[^%w%.%-]", "")
+ if host == "" then error("gemini url has no usable host: " .. url, 0) end
+
+ return {
+ host = host,
+ port = port,
+ path = path,
+ query = query ~= "" and query or nil,
+ title = authority .. path,
+ }
+end
+
+local function url_string (url)
+ local port = url.port ~= _M.default_port and (":" .. url.port) or ""
+ return "gemini://" .. url.host .. port .. url.path
+ .. (url.query and ("?" .. url.query) or "")
+end
+
+--- Resolve a link target against the page it was found on.
+-- @tparam string target The link target, absolute or relative.
+-- @tparam table base The parsed url of the page holding the link.
+-- @treturn string An absolute url.
+_M.resolve = function (target, base)
+ if target:match("^%a[%w+.-]*:") then return target end
+
+ local port = base.port ~= _M.default_port and (":" .. base.port) or ""
+ local root = "gemini://" .. base.host .. port
+
+ if target:sub(1, 2) == "//" then return "gemini:" .. target end
+ if target:sub(1, 1) == "/" then return root .. target end
+
+ local dir = base.path:match("^(.*/)") or "/"
+ return root .. dir .. target
+end
+
+-- ---------------------------------------------------------------------------
+-- gemtext
+-- ---------------------------------------------------------------------------
+
+--- Render gemtext as HTML.
+--
+-- The whole format is six line types: links, three heading levels, list items,
+-- quotes, and a fence that toggles preformatted blocks.
+--
+-- @tparam string text The gemtext body.
+-- @tparam table base The parsed url it came from, for resolving links.
+-- @treturn string Markup for the document body.
+_M.gemtext_to_html = function (text, base)
+ local esc = smallweb.escape
+ local out, pre, list = {}, false, false
+
+ local function close_list ()
+ if list then out[#out+1] = "</ul>" list = false end
+ end
+
+ for line in (text .. "\n"):gmatch("(.-)\r?\n") do
+ if line:sub(1, 3) == "```" then
+ close_list()
+ if pre then
+ out[#out+1] = "</pre>"
+ else
+ local alt = line:sub(4)
+ out[#out+1] = alt ~= ""
+ and ('<pre aria-label="' .. esc(alt) .. '">') or "<pre>"
+ end
+ pre = not pre
+ elseif pre then
+ out[#out+1] = esc(line)
+ elseif line:sub(1, 2) == "=>" then
+ close_list()
+ local target, label = line:match("^=>%s*(%S+)%s*(.-)$")
+ if target then
+ local href = _M.resolve(target, base)
+ out[#out+1] = '<div class="menu"><a href="' .. esc(href)
+ .. '">' .. esc(label ~= "" and label or target) .. "</a></div>"
+ end
+ elseif line:sub(1, 1) == "#" then
+ close_list()
+ local hashes, body = line:match("^(#+)%s*(.*)$")
+ local level = math.min(#hashes, 3)
+ out[#out+1] = "<h" .. level .. ' class="heading">' .. esc(body)
+ .. "</h" .. level .. ">"
+ elseif line:sub(1, 2) == "* " then
+ if not list then out[#out+1] = "<ul>" list = true end
+ out[#out+1] = "<li>" .. esc(line:sub(3)) .. "</li>"
+ elseif line:sub(1, 1) == ">" then
+ close_list()
+ out[#out+1] = '<p class="quote">' .. esc((line:sub(2):gsub("^%s", "")))
+ .. "</p>"
+ elseif line == "" then
+ close_list()
+ out[#out+1] = "<br>"
+ else
+ close_list()
+ out[#out+1] = "<p>" .. esc(line) .. "</p>"
+ end
+ end
+
+ if pre then out[#out+1] = "</pre>" end
+ close_list()
+
+ return table.concat(out, "\n")
+end
+
+-- ---------------------------------------------------------------------------
+-- Transport
+-- ---------------------------------------------------------------------------
+
+local tls_params = {
+ mode = "client",
+ protocol = "any",
+ -- Verification is ours, not OpenSSL's: gemini has no certificate
+ -- authorities, so a chain to a known root is not the question. What
+ -- matters is whether the same certificate came back, which tofu answers.
+ verify = "none",
+ options = { "all", "no_sslv2", "no_sslv3", "no_tlsv1", "no_tlsv1_1" },
+}
+
+local function fingerprint_of (conn)
+ local cert = conn.getpeercertificate and conn:getpeercertificate()
+ if not cert then error("the server sent no certificate", 0) end
+ local digest = cert:digest("sha256")
+ if not digest or digest == "" then
+ error("could not fingerprint the server certificate", 0)
+ end
+ return (digest:upper():gsub("[^%x]", ""))
+end
+
+-- One transaction, written so it can be driven a step at a time from a timer
+-- rather than blocking the interface.
+local function request (url)
+ local sock = socket.tcp()
+ sock:settimeout(0)
+
+ local deadline = os.time() + _M.timeout
+ local function expired ()
+ if os.time() > deadline then
+ error("timed out talking to " .. url.host, 0)
+ end
+ end
+
+ local ok, err = sock:connect(url.host, url.port)
+ if not ok and err ~= "timeout" and err ~= "Operation already in progress" then
+ error("cannot reach " .. url.host .. ": " .. tostring(err), 0)
+ end
+ while not ok do
+ if coroutine.yield() then sock:close() return end
+ expired()
+ local _, ready, serr = socket.select(nil, {sock}, 0)
+ if (ready or {})[sock] then break end
+ if serr and serr ~= "timeout" then
+ error("cannot reach " .. url.host .. ": " .. tostring(serr), 0)
+ end
+ end
+
+ local params = {}
+ for k, v in pairs(tls_params) do params[k] = v end
+ -- Without SNI a shared host cannot tell which certificate to send.
+ params.sni = url.host
+
+ local conn
+ conn, err = ssl.wrap(sock, params)
+ if not conn then
+ sock:close()
+ error("cannot start TLS: " .. tostring(err), 0)
+ end
+ conn:settimeout(0)
+ if conn.sni then pcall(conn.sni, conn, url.host) end
+
+ while true do
+ local done, herr = conn:dohandshake()
+ if done then break end
+ if herr ~= "wantread" and herr ~= "wantwrite" and herr ~= "timeout" then
+ conn:close()
+ error("TLS handshake failed: " .. tostring(herr), 0)
+ end
+ if coroutine.yield() then conn:close() return end
+ expired()
+ end
+
+ local status, stored = tofu.check(
+ url.host .. ":" .. url.port, fingerprint_of(conn))
+ if status == "changed" then
+ conn:close()
+ error("CERTIFICATE_CHANGED\t" .. tostring(stored), 0)
+ end
+
+ local message = url_string(url) .. "\r\n"
+ local sent = 0
+ while sent < #message do
+ local n, serr, last = conn:send(message, sent + 1)
+ if n then
+ sent = n
+ elseif serr == "wantwrite" or serr == "wantread" or serr == "timeout" then
+ sent = last or sent
+ else
+ conn:close()
+ error("could not send the request: " .. tostring(serr), 0)
+ end
+ if sent < #message then
+ if coroutine.yield() then conn:close() return end
+ expired()
+ end
+ end
+
+ local chunks = {}
+ while true do
+ local data, rerr, partial = conn:receive("*a")
+ if data then chunks[#chunks+1] = data break end
+ if rerr == "closed" then chunks[#chunks+1] = partial or "" break end
+ if rerr ~= "wantread" and rerr ~= "wantwrite" and rerr ~= "timeout" then
+ conn:close()
+ error("could not read the reply: " .. tostring(rerr), 0)
+ end
+ if partial and partial ~= "" then chunks[#chunks+1] = partial end
+ if coroutine.yield() then conn:close() return end
+ expired()
+ end
+
+ conn:close()
+
+ local body = table.concat(chunks)
+ local header, rest = body:match("^(.-)\r?\n(.*)$")
+ if not header then error("the reply had no status line", 0) end
+
+ local code, meta = header:match("^(%d%d)%s*(.*)$")
+ if not code then
+ error("the status line made no sense: " .. header:sub(1, 60), 0)
+ end
+
+ return { code = code, meta = meta or "", body = rest or "" }
+end
+
+-- ---------------------------------------------------------------------------
+-- Presentation
+-- ---------------------------------------------------------------------------
+
+local status_text = {
+ ["10"] = "The server is asking for input.",
+ ["11"] = "The server is asking for input, and will not echo it.",
+ ["40"] = "The server is temporarily unavailable.",
+ ["41"] = "The server is unavailable.",
+ ["42"] = "A CGI process on the server failed.",
+ ["43"] = "A proxy the server relies on failed.",
+ ["44"] = "The server is asking you to slow down.",
+ ["50"] = "The request was refused.",
+ ["51"] = "Not found.",
+ ["52"] = "Gone. The resource will not come back.",
+ ["53"] = "This server does not proxy for that host.",
+ ["59"] = "The server did not understand the request.",
+ ["60"] = "A client certificate is required, which Skull does not send yet.",
+ ["61"] = "The client certificate was not authorised.",
+ ["62"] = "The client certificate was not valid.",
+}
+
+local function input_page (url, meta, secret)
+ local prompt = smallweb.escape(meta ~= "" and meta or "Input")
+ local target = url_string({ host = url.host, port = url.port, path = url.path })
+ local body = table.concat({
+ '<h1 class="heading">', prompt, "</h1>",
+ '<form onsubmit="return go()">',
+ '<input id="q" type="', secret and "password" or "text",
+ '" size="48" autofocus>',
+ "</form>",
+ "<script>function go(){",
+ "var v=encodeURIComponent(document.getElementById('q').value);",
+ "window.location=", string.format("%q", target), "+'?'+v; return false;}",
+ "</script>",
+ })
+ return smallweb.document(url.title, body)
+end
+
+local function body_to_page (reply, url)
+ local mime = reply.meta:match("^([^;]*)") or "text/gemini"
+ mime = mime:gsub("%s", "")
+ if mime == "" then mime = "text/gemini" end
+
+ if mime == "text/gemini" then
+ return smallweb.document(url.title,
+ _M.gemtext_to_html(reply.body, url)), "text/html"
+ end
+
+ if mime:match("^text/") then
+ return smallweb.document(url.title,
+ "<pre>" .. smallweb.escape(reply.body) .. "</pre>"), "text/html"
+ end
+
+ -- Images and everything else go to WebKit untouched.
+ return reply.body, mime
+end
+
+-- ---------------------------------------------------------------------------
+-- Wiring
+-- ---------------------------------------------------------------------------
+
+local load_timer = timer{ interval = 50 }
+local loads = {}
+
+load_timer:add_signal("timeout", function ()
+ if not next(loads) then load_timer:stop() end
+end)
+
+local function remove_loader (v, loader)
+ local co = loads[v]
+ if co then
+ loads[v] = nil
+ coroutine.resume(co, "stop")
+ end
+ if loader then load_timer:remove_signal("timeout", loader) end
+end
+
+local function show_error (v, request_obj, heading, reason, buttons)
+ pcall(error_page.show_error_page, v, {
+ heading = heading,
+ content = smallweb.escape(reason),
+ buttons = buttons,
+ request = request_obj,
+ })
+end
+
+local function handle_reply (v, request_obj, reply, url)
+ local class = reply.code:sub(1, 1)
+
+ if class == "2" then
+ local html, mime = body_to_page(reply, url)
+ request_obj:finish(html, mime)
+ return
+ end
+
+ if class == "1" then
+ request_obj:finish(input_page(url, reply.meta, reply.code == "11"),
+ "text/html")
+ return
+ end
+
+ if class == "3" then
+ local target = _M.resolve(reply.meta, url)
+ -- The redirect is handed back to the browser rather than followed
+ -- here, so the address bar tells the truth and a redirect that leaves
+ -- gemini reaches the handler that can serve it.
+ request_obj:finish(smallweb.document(url.title,
+ '<p>Redirected to <a href="' .. smallweb.escape(target) .. '">'
+ .. smallweb.escape(target) .. "</a></p>"
+ .. "<script>window.location=" .. string.format("%q", target)
+ .. ";</script>"), "text/html")
+ return
+ end
+
+ show_error(v, request_obj, "Gemini Request Failed",
+ (status_text[reply.code] or ("The server answered " .. reply.code .. "."))
+ .. (reply.meta ~= "" and ("\n\n" .. reply.meta) or ""))
+end
+
+webview.add_signal("init", function (view)
+ view:add_signal("scheme-request::gemini", function (v, uri, request_obj)
+ if not (socket_loaded and ssl_loaded) then
+ local missing = {}
+ if not socket_loaded then missing[#missing+1] = "luasocket" end
+ if not ssl_loaded then missing[#missing+1] = "luasec" end
+ show_error(v, request_obj, "Gemini Support Is Not Installed",
+ "gemini:// needs " .. table.concat(missing, " and ")
+ .. ". Install it and restart:\n\n"
+ .. " sh build-utils/setup-deps.sh")
+ return
+ end
+
+ local ok, url = pcall(_M.parse_url, uri)
+ if not ok or not url then
+ show_error(v, request_obj, "Gemini Address Not Understood",
+ tostring(url or uri))
+ return
+ end
+
+ local co = coroutine.create(function () return request(url) end)
+ if not load_timer.started then load_timer:start() end
+ loads[v] = co
+
+ local function loader ()
+ if loads[v] ~= co then return remove_loader(v, loader) end
+
+ local alive = pcall(function () return v.is_loading end)
+ local running, res = coroutine.resume(co, not alive)
+
+ if not running then
+ if not request_obj.finished then
+ local stored = tostring(res):match("^CERTIFICATE_CHANGED\t(.*)$")
+ if stored then
+ local host = url.host .. ":" .. url.port
+ show_error(v, request_obj,
+ "This Host Changed Its Certificate",
+ "The certificate " .. host .. " presents is not the"
+ .. " one it presented before.\n\nGemini has no"
+ .. " certificate authorities, so the only check is"
+ .. " that the certificate stays the same. This can"
+ .. " mean the operator rotated it, or that"
+ .. " something is sitting between you and the"
+ .. " host.\n\nOn record: " .. stored,
+ {{
+ label = "Trust the new certificate",
+ callback = function (vv)
+ tofu.forget(host)
+ vv:reload()
+ end,
+ }})
+ else
+ show_error(v, request_obj, "Gemini Request Failed",
+ tostring(res))
+ end
+ end
+ return remove_loader(v, loader)
+ end
+
+ if not res then return end
+
+ if not request_obj.finished then
+ local handled, err = pcall(handle_reply, v, request_obj, res, url)
+ if not handled and not request_obj.finished then
+ show_error(v, request_obj, "Gemini Reply Not Understood",
+ tostring(err))
+ end
+ end
+ remove_loader(v, loader)
+ end
+
+ load_timer:add_signal("timeout", loader)
+ end)
+
+ view:add_signal("load-status", function (v, status)
+ if status == "failed" then remove_loader(v) end
+ end)
+end)
+
+return _M
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -0,0 +1,128 @@+--- Shared presentation layer for the small web.
+--
+-- `gopher://` and `gemini://` arrive as plain text over a plain socket and
+-- leave as HTML. The parts that are the same for both, meaning the page shell,
+-- the stylesheet and the choice between the page and terminal looks, live
+-- here so the two schemes cannot drift apart.
+--
+-- ## Render modes
+--
+-- `smallweb.render_mode` takes `page` or `terminal`.
+--
+-- `page` reads like a document: comfortable measure, ordinary link colour.
+--
+-- `terminal` reads like a terminal: full width, fixed columns, a caret on
+-- every link and a phosphor cast over the whole thing. It is the default,
+-- because it is what these protocols were written against.
+--
+-- @module smallweb
+-- @copyright 2026 Pablo Murad <pablomurad@pm.me>
+
+local lousy = require("lousy")
+local settings = require("settings")
+
+local _M = {}
+
+settings.register_settings({
+ ["smallweb.render_mode"] = {
+ type = "enum",
+ options = {
+ page = { desc = "Read as a document, with a comfortable measure." },
+ terminal = { desc = "Read as a terminal, full width and monospaced." },
+ },
+ default = "terminal",
+ desc = [=[
+ How `gopher://` and `gemini://` pages are presented.
+ ]=],
+ },
+})
+
+--- Return the palette for the current mode, taken from `theme.lua`.
+-- @treturn table Fields `bg`, `fg` and `link`.
+_M.palette = function ()
+ local theme = lousy.theme.get()
+ local dark = settings.get_setting("application.prefer_dark_mode")
+ local pal = dark and theme.gopher_dark or theme.gopher_light
+ if not pal then
+ msg.warn("smallweb: no small web palette in theme.lua, using defaults")
+ return dark and { bg = "#0b0d11", fg = "#e9e5d9", link = "#35d6c3" }
+ or { bg = "#f4f2ec", fg = "#15181d", link = "#0d7d71" }
+ end
+ return pal
+end
+
+--- The stylesheet for a small web page, including the `<style>` element.
+-- @treturn string
+_M.stylesheet = function ()
+ local pal = _M.palette()
+ local terminal = settings.get_setting("smallweb.render_mode") == "terminal"
+
+ local shell = terminal
+ and [[
+ body { max-width: none; padding: 1.25rem 1.5rem; }
+ pre, .menu { font-size: 13px; line-height: 1.45; }
+ a::before { content: "> "; opacity: 0.55; }
+ .heading { color: ]] .. pal.link .. [[; font-weight: 600; }
+ .quote { opacity: 0.75; border-left: 2px solid currentColor;
+ padding-left: 0.9em; margin-left: 0; }
+ ]]
+ or [[
+ body { max-width: 80ex; padding: 1.5rem; margin: 0 auto; }
+ pre, .menu { font-size: 14px; line-height: 1.6; }
+ .heading { font-weight: 600; }
+ .quote { opacity: 0.8; border-left: 3px solid currentColor;
+ padding-left: 1em; margin-left: 0; }
+ ]]
+
+ return [[
+ <style>
+ body, pre, input, .menu {
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ }
+ body {
+ background-color: ]] .. pal.bg .. [[;
+ color: ]] .. pal.fg .. [[;
+ margin: 0;
+ }
+ a, a:active, a:visited {
+ text-decoration: none;
+ color: ]] .. pal.link .. [[;
+ }
+ a:hover { text-decoration: underline; }
+ h1, h2, h3 { font-size: 1em; margin: 1.2em 0 0.4em; }
+ input {
+ background: transparent;
+ color: inherit;
+ border: 1px solid currentColor;
+ padding: 0.15em 0.4em;
+ }
+ ]] .. shell .. [[
+ </style>
+ ]]
+end
+
+--- Wrap rendered body markup in a complete document.
+-- @tparam string title The page title. Escaped here, pass it raw.
+-- @tparam string body Markup for the document body. Passed through as is.
+-- @tparam[opt] string head Extra markup for the document head.
+-- @treturn string
+_M.document = function (title, body, head)
+ return table.concat({
+ "<html><head><title>", lousy.util.escape(tostring(title or "")), "</title>",
+ '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />',
+ _M.stylesheet(), head or "", "</head><body>", body, "</body></html>",
+ })
+end
+
+--- Escape text for HTML, including the braces that `error_page` reads as
+-- placeholders.
+-- @tparam string text The text to escape.
+-- @treturn string
+_M.escape = function (text)
+ return (lousy.util.escape(tostring(text))
+ :gsub("{", "{"):gsub("}", "}"))
+end
+
+return _M
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -0,0 +1,116 @@+--- Trust on first use for the small web.
+--
+-- Gemini does not use certificate authorities. A server presents a
+-- self-signed certificate, the client remembers it, and what matters from then
+-- on is that the same certificate comes back. That is trust on first use.
+--
+-- The first visit to a host is trusted without asking, because there is
+-- nothing to compare against. Every visit after that compares fingerprints,
+-- and a change is reported rather than accepted.
+--
+-- @module smallweb.tofu
+-- @copyright 2026 Pablo Murad <pablomurad@pm.me>
+
+local _M = {}
+
+--- Path to the fingerprint store.
+-- @type string
+-- @readwrite
+_M.db_path = luakit.data_dir .. "/smallweb_tofu.db"
+
+local db
+
+local function init_db ()
+ if db then return db end
+ db = sqlite3{ filename = _M.db_path }
+ db:exec [[
+ PRAGMA synchronous = OFF;
+ PRAGMA secure_delete = 1;
+
+ CREATE TABLE IF NOT EXISTS known_hosts (
+ id INTEGER PRIMARY KEY,
+ host TEXT NOT NULL,
+ fingerprint TEXT NOT NULL,
+ first_seen INTEGER NOT NULL,
+ last_seen INTEGER NOT NULL
+ );
+
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_tofu_host ON known_hosts (host);
+ ]]
+ return db
+end
+
+--- Check a fingerprint against what is on record, and record it when the host
+-- is new.
+--
+-- @tparam string host The host, including the port.
+-- @tparam string fingerprint The certificate fingerprint presented now.
+-- @treturn string One of `"new"`, `"known"` or `"changed"`.
+-- @treturn string|nil The fingerprint on record, when the answer is
+-- `"changed"`.
+_M.check = function (host, fingerprint)
+ assert(type(host) == "string" and host ~= "", "expected a host")
+ assert(type(fingerprint) == "string" and fingerprint ~= "",
+ "expected a fingerprint")
+
+ local rows = init_db():exec(
+ "SELECT fingerprint FROM known_hosts WHERE host=?", {host})
+
+ if not rows or #rows == 0 then
+ local now = os.time()
+ db:exec("INSERT INTO known_hosts VALUES (NULL, ?, ?, ?, ?)",
+ {host, fingerprint, now, now})
+ return "new"
+ end
+
+ if rows[1].fingerprint == fingerprint then
+ db:exec("UPDATE known_hosts SET last_seen=? WHERE host=?",
+ {os.time(), host})
+ return "known"
+ end
+
+ return "changed", rows[1].fingerprint
+end
+
+--- Replace the fingerprint on record for a host.
+--
+-- Called when the user decides that a changed certificate is a legitimate
+-- rotation rather than someone in the middle.
+--
+-- @tparam string host The host, including the port.
+-- @tparam string fingerprint The fingerprint to trust from now on.
+_M.trust = function (host, fingerprint)
+ assert(type(host) == "string" and host ~= "", "expected a host")
+ assert(type(fingerprint) == "string" and fingerprint ~= "",
+ "expected a fingerprint")
+ local now = os.time()
+ init_db():exec("INSERT OR REPLACE INTO known_hosts VALUES "
+ .. "(NULL, ?, ?, COALESCE((SELECT first_seen FROM known_hosts "
+ .. "WHERE host=?), ?), ?)",
+ {host, fingerprint, host, now, now})
+end
+
+--- Forget a host, so the next visit is treated as a first one.
+-- @tparam string host The host, including the port.
+-- @treturn boolean Whether anything was removed.
+_M.forget = function (host)
+ assert(type(host) == "string" and host ~= "", "expected a host")
+ local rows = init_db():exec(
+ "SELECT COUNT(*) AS n FROM known_hosts WHERE host=?", {host})
+ local n = tonumber(rows and rows[1] and rows[1].n) or 0
+ if n == 0 then return false end
+ db:exec("DELETE FROM known_hosts WHERE host=?", {host})
+ return true
+end
+
+--- List every host on record.
+-- @treturn table Rows with `host`, `fingerprint`, `first_seen` and
+-- `last_seen`.
+_M.known_hosts = function ()
+ return init_db():exec("SELECT host, fingerprint, first_seen, last_seen "
+ .. "FROM known_hosts ORDER BY last_seen DESC") or {}
+end
+
+return _M
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -0,0 +1,79 @@+--- Test the gemini module's parsing and rendering.
+--
+-- The transport needs a network and a TLS library, so it is left to a live
+-- run. What is pinned here is the pure logic: url parsing, link resolution,
+-- and turning gemtext into HTML without letting hostile input become markup.
+
+local assert = require "luassert"
+local gemini = require "gemini"
+
+local T = {}
+
+T.test_parse_url_fills_defaults = function ()
+ local u = gemini.parse_url("gemini://example.org/")
+ assert.equal("example.org", u.host)
+ assert.equal(1965, u.port)
+ assert.equal("/", u.path)
+ assert.is_nil(u.query)
+end
+
+T.test_parse_url_keeps_port_and_query = function ()
+ local u = gemini.parse_url("gemini://example.org:1966/path?search")
+ assert.equal("example.org", u.host)
+ assert.equal(1966, u.port)
+ assert.equal("/path", u.path)
+ assert.equal("search", u.query)
+end
+
+T.test_parse_url_strips_a_hostile_host = function ()
+ -- Whatever survives reaches a TLS handshake and a fingerprint key.
+ local u = gemini.parse_url("gemini://ev il;rm$(id)/x")
+ assert.is_nil(u.host:match("[^%w%.%-]"))
+end
+
+T.test_resolve_handles_relative_and_absolute = function ()
+ local base = gemini.parse_url("gemini://h.org/dir/page.gmi")
+ assert.equal("gemini://h.org/dir/other.gmi", gemini.resolve("other.gmi", base))
+ assert.equal("gemini://h.org/root.gmi", gemini.resolve("/root.gmi", base))
+ assert.equal("https://elsewhere/x", gemini.resolve("https://elsewhere/x", base))
+ assert.equal("gemini://other.host/", gemini.resolve("//other.host/", base))
+end
+
+T.test_gemtext_links_and_headings = function ()
+ local base = gemini.parse_url("gemini://h.org/")
+ local html = gemini.gemtext_to_html(
+ "# Title\n=> /page.gmi A link\n* item\n> quoted\nplain\n", base)
+ assert.is_truthy(html:find("<h1", 1, true))
+ assert.is_truthy(html:find('href="gemini://h.org/page.gmi"', 1, true))
+ assert.is_truthy(html:find("<li>item</li>", 1, true))
+ assert.is_truthy(html:find("quoted", 1, true))
+end
+
+T.test_gemtext_escapes_markup = function ()
+ local base = gemini.parse_url("gemini://h.org/")
+ -- A link label, a heading and a plain line, each carrying markup and a
+ -- brace that error_page would otherwise read as a placeholder.
+ local html = gemini.gemtext_to_html(
+ "# <script>alert(1)</script>\n"
+ .. "=> /x <img onerror=go>{x}\n"
+ .. "plain <b>no</b> {y}\n", base)
+ assert.is_nil(html:find("<script>", 1, true))
+ assert.is_nil(html:find("<img onerror", 1, true))
+ assert.is_nil(html:find("<b>no</b>", 1, true))
+ assert.is_nil(html:find("{x}", 1, true))
+ assert.is_nil(html:find("{y}", 1, true))
+end
+
+T.test_gemtext_preformatted_is_literal = function ()
+ local base = gemini.parse_url("gemini://h.org/")
+ local html = gemini.gemtext_to_html(
+ "```\n=> not a link\n# not a heading\n```\n", base)
+ -- Inside a fence, nothing is interpreted as a link or heading.
+ assert.is_nil(html:find("<a ", 1, true))
+ assert.is_nil(html:find("<h1", 1, true))
+ assert.is_truthy(html:find("=> not a link", 1, true))
+end
+
+return T
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80
@@ -0,0 +1,60 @@+--- Test trust on first use for the small web.
+--
+-- The whole point of TOFU is that the first certificate is remembered and a
+-- change is caught. These pin exactly that: new, then known, then changed,
+-- and the two ways out, trusting the new one or forgetting the host.
+
+local assert = require "luassert"
+local tofu = require "smallweb.tofu"
+
+local T = {}
+
+local function clean (host) tofu.forget(host) end
+
+T.test_first_visit_is_new_then_known = function ()
+ local host = "tofu-test-a:1965"
+ clean(host)
+
+ assert.equal("new", tofu.check(host, "AA:BB"))
+ -- Same certificate on the next visit is known, not new.
+ assert.equal("known", tofu.check(host, "AA:BB"))
+ clean(host)
+end
+
+T.test_a_changed_certificate_is_reported = function ()
+ local host = "tofu-test-b:1965"
+ clean(host)
+
+ tofu.check(host, "AA:BB")
+ local status, stored = tofu.check(host, "CC:DD")
+ assert.equal("changed", status)
+ assert.equal("AA:BB", stored)
+ clean(host)
+end
+
+T.test_trusting_the_new_certificate_replaces_it = function ()
+ local host = "tofu-test-c:1965"
+ clean(host)
+
+ tofu.check(host, "AA:BB")
+ tofu.trust(host, "CC:DD")
+ -- The new one is now the known one, and the old one would now be a change.
+ assert.equal("known", tofu.check(host, "CC:DD"))
+ clean(host)
+end
+
+T.test_forgetting_a_host_makes_it_new_again = function ()
+ local host = "tofu-test-d:1965"
+ clean(host)
+
+ tofu.check(host, "AA:BB")
+ assert.is_true(tofu.forget(host))
+ assert.equal("new", tofu.check(host, "ZZ:ZZ"))
+ -- Forgetting something that is not there says so.
+ clean(host)
+ assert.is_false(tofu.forget(host))
+end
+
+return T
+
+-- vim: et:sw=4:ts=8:sts=4:tw=80