Skip to main content

netget/server/gemini/
wire.rs

1//! Gemini wire format: request validation, the response header, and gemtext rendering.
2//!
3//! Pure functions, shared by the session loop, the action executor and the tests. The model
4//! supplies a status, a line of meta and structured gemtext lines; everything that makes a
5//! Gemini response well-formed — the two-digit code, the single space, the CRLF, the 1024-byte
6//! meta bound, the body appearing only after a 2x, and gemtext line prefixes that cannot be
7//! confused with one another — is decided here.
8
9use crate::utils::sanitize;
10
11/// The longest URL a request may carry, in bytes, excluding its CRLF.
12///
13/// Gemini specification §"Requests": "The URI MUST NOT exceed 1024 bytes". A longer one is
14/// refused with `59` before anything else happens.
15pub const MAX_URL_BYTES: usize = 1024;
16
17/// The longest request line this server buffers: [`MAX_URL_BYTES`] plus CRLF. This is the
18/// declared `max_inbound_bytes` — a client sends nothing else on a connection.
19pub const MAX_REQUEST_BYTES: usize = MAX_URL_BYTES + 2;
20
21/// The longest meta field a response may carry (§"Responses": META MUST NOT exceed 1024 bytes).
22pub const MAX_META_BYTES: usize = 1024;
23
24/// The MIME type NetGet's rendered gemtext declares.
25pub const GEMTEXT_MIME: &str = "text/gemini; charset=utf-8";
26
27/// Every status code the specification defines, with the meta a response gets when the model
28/// leaves it empty. A response header is always `<code> <meta>` with a non-empty meta: some
29/// clients split the header on whitespace and fail outright on a bare `51\r\n`.
30pub const STATUS_CODES: &[(u16, &str)] = &[
31    (10, "Input"),
32    (11, "Sensitive input"),
33    (20, GEMTEXT_MIME),
34    (30, ""),
35    (31, ""),
36    (40, "Temporary failure"),
37    (41, "Server unavailable"),
38    (42, "CGI error"),
39    (43, "Proxy error"),
40    (44, "60"),
41    (50, "Permanent failure"),
42    (51, "Not found"),
43    (52, "Gone"),
44    (53, "Proxy request refused"),
45    (59, "Bad request"),
46    (60, "Client certificate required"),
47    (61, "Certificate not authorised"),
48    (62, "Certificate not valid"),
49];
50
51/// A request that passed validation, as the model sees it.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct GeminiRequest {
54    /// The URL exactly as the client sent it.
55    pub url: String,
56    pub host: String,
57    /// The path as it appears in the URL (not decoded); `/` when empty.
58    pub path: String,
59    /// The query, percent-decoded; `None` when the URL has no `?`.
60    pub query: Option<String>,
61}
62
63/// Why a request line was refused before reaching the model, with the response it gets.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum RequestRefusal {
66    /// `59`: not an absolute URL, a BOM, userinfo, a fragment, no host, or too long.
67    BadRequest(&'static str),
68    /// `53`: an absolute URL for a scheme other than `gemini` — a proxy request, which this
69    /// server does not serve.
70    ProxyRefused,
71}
72
73impl RequestRefusal {
74    /// The complete response for this refusal. Fixed text: nothing the client sent is echoed.
75    pub fn response(&self) -> String {
76        match self {
77            RequestRefusal::BadRequest(why) => format!("59 {why}\r\n"),
78            RequestRefusal::ProxyRefused => "53 Proxy request refused\r\n".to_string(),
79        }
80    }
81
82    /// The `decision=` token the session logs for this refusal.
83    pub fn decision(&self) -> &'static str {
84        match self {
85            RequestRefusal::BadRequest(_) => "refused_bad_request",
86            RequestRefusal::ProxyRefused => "refused_proxy_request",
87        }
88    }
89}
90
91/// Validate a request line (without its CRLF).
92pub fn parse_request(line: &str) -> Result<GeminiRequest, RequestRefusal> {
93    if line.len() > MAX_URL_BYTES {
94        return Err(RequestRefusal::BadRequest("Request too long"));
95    }
96    if line.starts_with('\u{feff}') {
97        return Err(RequestRefusal::BadRequest(
98            "Request must not begin with a BOM",
99        ));
100    }
101    let url = url::Url::parse(line)
102        .map_err(|_| RequestRefusal::BadRequest("Request is not an absolute URL"))?;
103    if url.scheme() != "gemini" {
104        return Err(RequestRefusal::ProxyRefused);
105    }
106    if !url.username().is_empty() || url.password().is_some() {
107        return Err(RequestRefusal::BadRequest("URL must not contain userinfo"));
108    }
109    if url.fragment().is_some() {
110        return Err(RequestRefusal::BadRequest(
111            "URL must not contain a fragment",
112        ));
113    }
114    let host = match url.host_str() {
115        Some(h) if !h.is_empty() => h.to_string(),
116        _ => return Err(RequestRefusal::BadRequest("URL has no host")),
117    };
118    let path = if url.path().is_empty() {
119        "/".to_string()
120    } else {
121        url.path().to_string()
122    };
123    Ok(GeminiRequest {
124        url: line.to_string(),
125        host,
126        path,
127        // Decoded for the model, which should see `hello world` rather than `hello%20world`,
128        // and then cleaned: `%1B` would otherwise put an escape sequence on the operator's
129        // screen through the event log. Newline and tab survive — a query can be multi-line
130        // input the model is meant to read.
131        query: url.query().map(|q| sanitize::multiline(&percent_decode(q))),
132    })
133}
134
135/// Decode `%XX` escapes (and nothing else — `+` is a plus sign in a Gemini query, which is
136/// not form encoding). Invalid escapes are kept literally; invalid UTF-8 is replaced.
137pub fn percent_decode(s: &str) -> String {
138    let bytes = s.as_bytes();
139    let mut out = Vec::with_capacity(bytes.len());
140    let mut i = 0;
141    while i < bytes.len() {
142        // Bytes, not `&s[..]`: an index into a multi-byte character would panic.
143        // Both digits checked explicitly: `from_str_radix` would accept "+1".
144        if bytes[i] == b'%'
145            && i + 2 < bytes.len()
146            && bytes[i + 1].is_ascii_hexdigit()
147            && bytes[i + 2].is_ascii_hexdigit()
148        {
149            let decoded = std::str::from_utf8(&bytes[i + 1..i + 3])
150                .ok()
151                .and_then(|hex| u8::from_str_radix(hex, 16).ok());
152            if let Some(b) = decoded {
153                out.push(b);
154                i += 3;
155                continue;
156            }
157        }
158        out.push(bytes[i]);
159        i += 1;
160    }
161    String::from_utf8_lossy(&out).into_owned()
162}
163
164/// Why a response could not be rendered.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum ResponseError {
167    UnknownStatus(u16),
168    MetaTooLong(usize),
169    MissingMeta(u16),
170    BadMeta(u16, &'static str),
171}
172
173impl std::fmt::Display for ResponseError {
174    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175        match self {
176            ResponseError::UnknownStatus(c) => {
177                let codes: Vec<String> = STATUS_CODES.iter().map(|(c, _)| c.to_string()).collect();
178                write!(
179                    f,
180                    "status {c} is not a Gemini status; use one of {}",
181                    codes.join(", ")
182                )
183            }
184            ResponseError::MetaTooLong(n) => {
185                write!(
186                    f,
187                    "meta is {n} bytes; Gemini allows at most {MAX_META_BYTES}"
188                )
189            }
190            ResponseError::MissingMeta(c) => write!(f, "status {c} requires a meta"),
191            ResponseError::BadMeta(c, why) => write!(f, "status {c}: {why}"),
192        }
193    }
194}
195
196impl std::error::Error for ResponseError {}
197
198/// Render a complete response: header, and the body only for a 2x.
199///
200/// The meta becomes one line (control characters → spaces, trimmed), an empty meta takes the
201/// status's default, and a body given with any status other than 2x is dropped — Gemini
202/// defines no body for them, and a client reading one would take it for the next response's
203/// garbage. The caller learns whether that happened from the second return value.
204pub fn render_response(
205    status: u16,
206    meta: &str,
207    body: Option<&str>,
208) -> Result<(String, bool), ResponseError> {
209    let (_, default_meta) = STATUS_CODES
210        .iter()
211        .find(|(c, _)| *c == status)
212        .ok_or(ResponseError::UnknownStatus(status))?;
213    let meta = sanitize::line_field(meta).trim().to_string();
214    let meta = if meta.is_empty() {
215        if default_meta.is_empty() {
216            return Err(ResponseError::MissingMeta(status));
217        }
218        default_meta.to_string()
219    } else {
220        meta
221    };
222    if meta.len() > MAX_META_BYTES {
223        return Err(ResponseError::MetaTooLong(meta.len()));
224    }
225    if status == 44 && !meta.chars().all(|c| c.is_ascii_digit()) {
226        return Err(ResponseError::BadMeta(
227            44,
228            "meta must be the number of seconds to wait",
229        ));
230    }
231    if (status == 30 || status == 31) && meta.contains(char::is_whitespace) {
232        return Err(ResponseError::BadMeta(status, "meta must be a URL"));
233    }
234    let mut out = format!("{status} {meta}\r\n");
235    let is_success = (20..30).contains(&status);
236    let dropped_body = !is_success && body.is_some_and(|b| !b.is_empty());
237    if is_success {
238        if let Some(body) = body {
239            out.push_str(body);
240        }
241    }
242    Ok((out, dropped_body))
243}
244
245/// One gemtext line as the model supplies it.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub enum GemtextLine {
248    Text(String),
249    Link { url: String, text: String },
250    Heading(u8, String),
251    ListItem(String),
252    Quote(String),
253    Preformatted { alt: String, text: String },
254}
255
256/// Why a gemtext line could not be rendered.
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum GemtextError {
259    UnknownType(String),
260    LinkWithoutUrl,
261    LinkUrlHasWhitespace,
262}
263
264impl std::fmt::Display for GemtextError {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        match self {
267            GemtextError::UnknownType(t) => write!(
268                f,
269                "unknown gemtext line type {t:?}; use text, link, heading1, heading2, heading3, \
270                 list, quote or preformatted"
271            ),
272            GemtextError::LinkWithoutUrl => write!(f, "a link line needs a 'url'"),
273            GemtextError::LinkUrlHasWhitespace => write!(
274                f,
275                "a link 'url' cannot contain whitespace (percent-encode it: %20)"
276            ),
277        }
278    }
279}
280
281impl std::error::Error for GemtextError {}
282
283impl GemtextLine {
284    pub fn from_parts(
285        kind: &str,
286        text: &str,
287        url: Option<&str>,
288        alt: Option<&str>,
289    ) -> Result<Self, GemtextError> {
290        Ok(match kind {
291            "text" => GemtextLine::Text(text.to_string()),
292            "link" => {
293                let url = url.map(str::trim).filter(|u| !u.is_empty());
294                let url = url.ok_or(GemtextError::LinkWithoutUrl)?;
295                if url.contains(char::is_whitespace) || sanitize::strip_controls(url) != url {
296                    return Err(GemtextError::LinkUrlHasWhitespace);
297                }
298                GemtextLine::Link {
299                    url: url.to_string(),
300                    text: text.to_string(),
301                }
302            }
303            "heading1" => GemtextLine::Heading(1, text.to_string()),
304            "heading2" => GemtextLine::Heading(2, text.to_string()),
305            "heading3" => GemtextLine::Heading(3, text.to_string()),
306            "list" => GemtextLine::ListItem(text.to_string()),
307            "quote" => GemtextLine::Quote(text.to_string()),
308            "preformatted" => GemtextLine::Preformatted {
309                alt: alt.unwrap_or("").to_string(),
310                text: text.to_string(),
311            },
312            other => return Err(GemtextError::UnknownType(other.to_string())),
313        })
314    }
315}
316
317/// The prefixes that give a gemtext line a type. A text line beginning with one would be read
318/// as that type.
319const LINE_TYPE_PREFIXES: &[&str] = &["=>", "#", "*", ">", "```"];
320
321/// Make a line safe as a *text* line: control characters out, and a leading space in front of
322/// anything that would otherwise read as a link, heading, list item, quote or toggle. Gemtext
323/// has no escape character; a text line is only a link when `=>` is at column 0, so one space
324/// is the least change that keeps the words and loses the misreading.
325fn plain(line: &str) -> String {
326    let clean = sanitize::strip_controls(line);
327    if LINE_TYPE_PREFIXES.iter().any(|p| clean.starts_with(p)) {
328        format!(" {clean}")
329    } else {
330        clean
331    }
332}
333
334/// One-line content for a typed line: control characters (including newlines) become spaces.
335fn one_line(s: &str) -> String {
336    sanitize::line_field(s).trim().to_string()
337}
338
339fn split_lines(text: &str) -> Vec<String> {
340    let normalised = text.replace("\r\n", "\n").replace('\r', "\n");
341    sanitize::multiline(&normalised)
342        .split('\n')
343        .map(str::to_string)
344        .collect()
345}
346
347/// Render gemtext. Every line ends in LF, which the specification allows and every client reads.
348pub fn render_gemtext(lines: &[GemtextLine]) -> String {
349    let mut out = String::new();
350    for line in lines {
351        match line {
352            GemtextLine::Text(text) => {
353                for l in split_lines(text) {
354                    out.push_str(&plain(&l));
355                    out.push('\n');
356                }
357            }
358            GemtextLine::Link { url, text } => {
359                let label = one_line(text);
360                if label.is_empty() {
361                    out.push_str(&format!("=> {url}\n"));
362                } else {
363                    out.push_str(&format!("=> {url} {label}\n"));
364                }
365            }
366            GemtextLine::Heading(level, text) => {
367                out.push_str(&"#".repeat(*level as usize));
368                out.push(' ');
369                out.push_str(&one_line(text));
370                out.push('\n');
371            }
372            GemtextLine::ListItem(text) => {
373                out.push_str("* ");
374                out.push_str(&one_line(text));
375                out.push('\n');
376            }
377            GemtextLine::Quote(text) => {
378                for l in split_lines(text) {
379                    out.push_str("> ");
380                    out.push_str(&sanitize::strip_controls(&l));
381                    out.push('\n');
382                }
383            }
384            GemtextLine::Preformatted { alt, text } => {
385                out.push_str("```");
386                out.push_str(&one_line(alt));
387                out.push('\n');
388                for l in split_lines(text) {
389                    // Inside a block only a line starting with ``` means anything (it closes
390                    // the block), so that is the one prefix to defuse.
391                    let l = sanitize::strip_controls(&l);
392                    if l.starts_with("```") {
393                        out.push(' ');
394                    }
395                    out.push_str(&l);
396                    out.push('\n');
397                }
398                out.push_str("```\n");
399            }
400        }
401    }
402    out
403}
404
405/// A `lang` parameter for the gemtext MIME type: BCP 47 tags, comma-separated. Anything else is
406/// dropped rather than written into the header.
407pub fn gemtext_mime(lang: Option<&str>) -> String {
408    match lang.map(str::trim).filter(|l| {
409        !l.is_empty()
410            && l.len() <= 64
411            && l.chars()
412                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == ',')
413    }) {
414        Some(lang) => format!("{GEMTEXT_MIME}; lang={lang}"),
415        None => GEMTEXT_MIME.to_string(),
416    }
417}
418
419/// The two-digit status a rendered response begins with.
420pub fn leading_status(response: &[u8]) -> Option<u16> {
421    let head = response.get(..2)?;
422    if !head.iter().all(u8::is_ascii_digit) || response.get(2) != Some(&b' ') {
423        return None;
424    }
425    std::str::from_utf8(head).ok()?.parse().ok()
426}