1use crate::utils::sanitize;
10
11pub const MAX_URL_BYTES: usize = 1024;
16
17pub const MAX_REQUEST_BYTES: usize = MAX_URL_BYTES + 2;
20
21pub const MAX_META_BYTES: usize = 1024;
23
24pub const GEMTEXT_MIME: &str = "text/gemini; charset=utf-8";
26
27pub 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#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct GeminiRequest {
54 pub url: String,
56 pub host: String,
57 pub path: String,
59 pub query: Option<String>,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum RequestRefusal {
66 BadRequest(&'static str),
68 ProxyRefused,
71}
72
73impl RequestRefusal {
74 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 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
91pub 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 query: url.query().map(|q| sanitize::multiline(&percent_decode(q))),
132 })
133}
134
135pub 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 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#[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
198pub 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#[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#[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
317const LINE_TYPE_PREFIXES: &[&str] = &["=>", "#", "*", ">", "```"];
320
321fn 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
334fn 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
347pub 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 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
405pub 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
419pub 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}