Skip to main content

netget/client/gemini/
wire.rs

1//! Gemini request encoding, bounded responses and structured gemtext.
2use anyhow::{bail, ensure, Context, Result};
3use serde_json::{json, Value};
4use std::time::Duration;
5use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt};
6pub const DEADLINE: Duration = Duration::from_secs(30);
7pub const MAX_BODY_BYTES: usize = 1024 * 1024;
8pub const MAX_LINES: usize = 8192;
9pub const MAX_HEADER_BYTES: usize = 1029;
10#[derive(Debug)]
11pub struct Request {
12    pub url: url::Url,
13    pub bytes: Vec<u8>,
14}
15impl Request {
16    pub fn from_action(v: &Value) -> Result<Self> {
17        let raw = v["url"].as_str().context("Missing Gemini URL")?;
18        ensure!(
19            !crate::utils::sanitize::has_controls(&raw),
20            "URL contains control characters"
21        );
22        crate::server::gemini::wire::parse_request(raw)
23            .map_err(|e| anyhow::anyhow!("Invalid Gemini URL: {e:?}"))?;
24        let mut url = url::Url::parse(raw)?;
25        if let Some(input) = v.get("input") {
26            let input = input.as_str().context("input must be text")?;
27            ensure!(input.len() <= 1024, "Input too long");
28            let mut query = String::new();
29            for b in input.bytes() {
30                if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
31                    query.push(b as char)
32                } else {
33                    query.push_str(&format!("%{b:02X}"));
34                }
35            }
36            url.set_query(Some(&query));
37        }
38        ensure!(url.as_str().len() <= 1024, "Gemini URL exceeds 1024 bytes");
39        ensure!(url.port().unwrap_or(1965) > 0, "Invalid Gemini port");
40        Ok(Self {
41            bytes: format!("{}\r\n", url.as_str()).into_bytes(),
42            url,
43        })
44    }
45}
46pub async fn response<R: AsyncBufRead + Unpin>(r: &mut R, request: &Request) -> Result<Value> {
47    let mut header = Vec::new();
48    loop {
49        let buf = r.fill_buf().await?;
50        ensure!(!buf.is_empty(), "Truncated Gemini header");
51        let n = buf
52            .iter()
53            .position(|b| *b == b'\n')
54            .map(|n| n + 1)
55            .unwrap_or(buf.len());
56        ensure!(
57            header.len() + n <= MAX_HEADER_BYTES,
58            "Gemini header exceeds1029 bytes"
59        );
60        header.extend_from_slice(&buf[..n]);
61        r.consume(n);
62        if header.ends_with(b"\n") {
63            break;
64        }
65    }
66    ensure!(header.ends_with(b"\r\n"), "Gemini requires CRLF");
67    header.truncate(header.len() - 2);
68    ensure!(
69        header.len() >= 3
70            && (b'1'..=b'6').contains(&header[0])
71            && header[1].is_ascii_digit()
72            && header[2] == b' ',
73        "Malformed Gemini status"
74    );
75    let status = (header[0] - b'0') * 10 + header[1] - b'0';
76    let meta = std::str::from_utf8(&header[3..])?;
77    ensure!(
78        !crate::utils::sanitize::has_controls(&meta),
79        "Control character in Gemini meta"
80    );
81    let mut out = json!({"status":status,"meta":meta});
82    match status / 10 {
83        1 => {
84            out["kind"] = json!("input");
85            out["sensitive"] = json!(status == 11);
86        }
87        2 => {
88            let media = if meta.is_empty() {
89                "text/gemini; charset=utf-8"
90            } else {
91                meta
92            };
93            let mime = media
94                .split(';')
95                .next()
96                .unwrap_or("")
97                .trim()
98                .to_ascii_lowercase();
99            ensure!(
100                mime.starts_with("text/"),
101                "Unsupported non-text Gemini MIME type: {mime}"
102            );
103            let mut ascii = false;
104            for field in media.split(';').skip(1) {
105                if let Some((name, value)) = field.split_once('=') {
106                    if name.trim().eq_ignore_ascii_case("charset") {
107                        let value = value.trim().trim_matches('"');
108                        ensure!(
109                            value.eq_ignore_ascii_case("utf-8")
110                                || value.eq_ignore_ascii_case("us-ascii"),
111                            "Unsupported text charset"
112                        );
113                        ascii = value.eq_ignore_ascii_case("us-ascii");
114                    }
115                }
116            }
117            let mut body = Vec::new();
118            r.take((MAX_BODY_BYTES + 1) as u64)
119                .read_to_end(&mut body)
120                .await?;
121            ensure!(body.len() <= MAX_BODY_BYTES, "Gemini body exceeds1MiB");
122            ensure!(
123                !ascii || body.is_ascii(),
124                "Non-ASCII body declares us-ascii"
125            );
126            let text = String::from_utf8(body).context("Gemini text is not UTF-8")?;
127            out["kind"] = json!("success");
128            out["mime_type"] = json!(mime);
129            if mime == "text/gemini" {
130                out["lines"] = gemtext(&text, &request.url)?;
131            }
132            out["text"] = json!(text);
133        }
134        3 => {
135            ensure!(!meta.is_empty(), "Redirect has no target");
136            let target = request.url.join(meta).context("Invalid redirect target")?;
137            out["kind"] = json!("redirect");
138            out["url"] = json!(target.as_str());
139            out["permanent"] = json!(status == 31);
140        }
141        4 => {
142            out["kind"] = json!("temporary_failure");
143            if status == 44 {
144                out["retry_after_secs"] =
145                    json!(meta.parse::<u64>().context("Invalid slow-down delay")?);
146            }
147        }
148        5 => out["kind"] = json!("permanent_failure"),
149        6 => out["kind"] = json!("certificate_required"),
150        _ => bail!("Invalid status class"),
151    }
152    Ok(out)
153}
154pub fn gemtext(text: &str, base: &url::Url) -> Result<Value> {
155    let mut out = Vec::new();
156    let mut pre: Option<(String, Vec<&str>)> = None;
157    for (index, line) in text.lines().enumerate() {
158        ensure!(index < MAX_LINES, "Too many gemtext lines");
159        if let Some(alt) = line.strip_prefix("```") {
160            if let Some((alt, lines)) = pre.take() {
161                out.push(json!({"type":"preformatted","alt":alt,"text":lines.join("\n")}));
162            } else {
163                pre = Some((alt.into(), Vec::new()));
164            }
165            continue;
166        }
167        if let Some((_, lines)) = &mut pre {
168            lines.push(line);
169            continue;
170        }
171        if let Some(link) = line.strip_prefix("=>") {
172            let link = link.trim_start();
173            let end = link.find(char::is_whitespace).unwrap_or(link.len());
174            let raw = &link[..end];
175            ensure!(!raw.is_empty(), "Empty gemtext link");
176            let target = base.join(raw).context("Invalid gemtext link")?;
177            out.push(json!({"type":"link","url":target.as_str(),"text":link[end..].trim()}));
178        } else if let Some(text) = line.strip_prefix("###") {
179            out.push(json!({"type":"heading3","text":text.trim_start()}));
180        } else if let Some(text) = line.strip_prefix("##") {
181            out.push(json!({"type":"heading2","text":text.trim_start()}));
182        } else if let Some(text) = line.strip_prefix('#') {
183            out.push(json!({"type":"heading1","text":text.trim_start()}));
184        } else if let Some(text) = line.strip_prefix("* ") {
185            out.push(json!({"type":"list","text":text}));
186        } else if let Some(text) = line.strip_prefix('>') {
187            out.push(json!({"type":"quote","text":text.trim_start()}));
188        } else {
189            out.push(json!({"type":"text","text":line}));
190        }
191    }
192    if let Some((alt, lines)) = pre {
193        out.push(json!({"type":"preformatted","alt":alt,"text":lines.join("\n")}));
194    }
195    Ok(json!(out))
196}