Skip to main content

solid_pod_rs_forge/
request.rs

1//! Binder-agnostic request/response types plus the XSS-neutralisation
2//! spine.
3//!
4//! Mirrors the shape of [`solid_pod_rs_git::service::GitRequest`] so the
5//! embedding server translates its native (actix/axum/hyper) types once,
6//! at the edge, and the forge logic stays framework-free and testable.
7//!
8//! Security posture (cites JSS `forge` design notes, not code): every
9//! byte the forge serves is neutralised by **content-type**, never by
10//! trusting HTML sanitisation. Raw repository bytes go out as
11//! `text/plain; charset=utf-8` or `application/octet-stream` with a
12//! `Content-Disposition: attachment`, so a browser never renders a blob
13//! as markup. The single [`esc`] helper HTML-escapes every value the
14//! server-rendered views interpolate.
15
16use bytes::Bytes;
17
18/// Opaque HTTP request consumed by [`crate::ForgeService::handle`].
19///
20/// The caller percent-decodes `path` and strips the leading `?` from
21/// `query` before constructing this, exactly as the git crate expects.
22#[derive(Debug, Clone)]
23pub struct ForgeRequest {
24    /// Upper- or mixed-case HTTP method (`"GET"`, `"POST"`, `"OPTIONS"`).
25    pub method: String,
26    /// Percent-decoded URL path, including the forge prefix
27    /// (`"/forge/alice/repo/issues"`).
28    pub path: String,
29    /// Raw query string with no leading `?` (`"state=open&page=2"`).
30    pub query: String,
31    /// All request headers as `(name, value)`. Names are matched
32    /// case-insensitively.
33    pub headers: Vec<(String, String)>,
34    /// Raw request body — kept as bytes so NIP-98 payload hashing sees
35    /// the exact octets.
36    pub raw_body: Bytes,
37    /// Scheme + host (`"https://pod.example.com"`); used to reconstruct
38    /// the URL a NIP-98 `u` tag must match and to render absolute links.
39    pub host_url: Option<String>,
40}
41
42impl ForgeRequest {
43    /// Case-insensitive header lookup returning the first match.
44    #[must_use]
45    pub fn header(&self, name: &str) -> Option<&str> {
46        self.headers
47            .iter()
48            .find(|(k, _)| k.eq_ignore_ascii_case(name))
49            .map(|(_, v)| v.as_str())
50    }
51
52    /// Reconstruct the canonical absolute URL (for NIP-98 `u`-tag checks).
53    #[must_use]
54    pub fn absolute_url(&self) -> String {
55        let base = self
56            .host_url
57            .clone()
58            .unwrap_or_else(|| "http://localhost".to_string());
59        if self.query.is_empty() {
60            format!("{base}{}", self.path)
61        } else {
62            format!("{base}{}?{}", self.path, self.query)
63        }
64    }
65
66    /// Parse a single query parameter value (first occurrence),
67    /// percent-decoding `+` to space. Returns `None` when absent.
68    #[must_use]
69    pub fn query_param(&self, key: &str) -> Option<String> {
70        for pair in self.query.split('&') {
71            let mut it = pair.splitn(2, '=');
72            let k = it.next().unwrap_or("");
73            if k == key {
74                let v = it.next().unwrap_or("");
75                return Some(v.replace('+', " "));
76            }
77        }
78        None
79    }
80}
81
82/// Response returned from [`crate::ForgeService::handle`]; the server maps
83/// it back to its native response type.
84#[derive(Debug, Clone)]
85pub struct ForgeResponse {
86    /// HTTP status code.
87    pub status: u16,
88    /// Response headers as `(name, value)`.
89    pub headers: Vec<(String, String)>,
90    /// Body bytes.
91    pub body: Bytes,
92}
93
94impl ForgeResponse {
95    /// Build a response with a single content-type header.
96    #[must_use]
97    pub fn with_type(status: u16, content_type: &str, body: impl Into<Bytes>) -> Self {
98        Self {
99            status,
100            headers: vec![("content-type".into(), content_type.into())],
101            body: body.into(),
102        }
103    }
104
105    /// Server-rendered HTML page. Callers must have `esc()`'d every
106    /// interpolated value.
107    #[must_use]
108    pub fn html(status: u16, body: impl Into<String>) -> Self {
109        Self::with_type(status, "text/html; charset=utf-8", Bytes::from(body.into()))
110    }
111
112    /// JSON response. `value` is any `serde_json::Value` or serialisable.
113    #[must_use]
114    pub fn json(status: u16, value: &serde_json::Value) -> Self {
115        let body = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());
116        Self::with_type(status, "application/json", Bytes::from(body))
117    }
118
119    /// Raw repository bytes served defensively: text blobs as
120    /// `text/plain`, everything else as an `octet-stream` attachment so
121    /// no browser ever renders repository content as markup.
122    #[must_use]
123    pub fn raw_bytes(bytes: Bytes, is_text: bool, filename: &str) -> Self {
124        if is_text {
125            Self {
126                status: 200,
127                headers: vec![
128                    ("content-type".into(), "text/plain; charset=utf-8".into()),
129                    // Even text is served non-inline-executable.
130                    ("x-content-type-options".into(), "nosniff".into()),
131                ],
132                body: bytes,
133            }
134        } else {
135            let safe = sanitize_filename(filename);
136            Self {
137                status: 200,
138                headers: vec![
139                    ("content-type".into(), "application/octet-stream".into()),
140                    (
141                        "content-disposition".into(),
142                        format!("attachment; filename=\"{safe}\""),
143                    ),
144                    ("x-content-type-options".into(), "nosniff".into()),
145                ],
146                body: bytes,
147            }
148        }
149    }
150
151    /// A 3xx redirect to `location`.
152    #[must_use]
153    pub fn redirect(status: u16, location: &str) -> Self {
154        Self {
155            status,
156            headers: vec![("location".into(), location.to_string())],
157            body: Bytes::new(),
158        }
159    }
160
161    /// A JSON `{"error": msg}` body at `status`.
162    #[must_use]
163    pub fn error(status: u16, msg: impl Into<String>) -> Self {
164        let v = serde_json::json!({ "error": msg.into() });
165        Self::json(status, &v)
166    }
167
168    /// Append a header (used for CORS on `/blocktrails.json`, etc.).
169    #[must_use]
170    pub fn with_header(mut self, name: &str, value: &str) -> Self {
171        self.headers.push((name.into(), value.into()));
172        self
173    }
174}
175
176/// HTML-escape the five significant characters. This is THE single
177/// interpolation helper — every value rendered into a server view passes
178/// through it. Ampersand is escaped first so a later `&lt;` is not
179/// double-escaped into `&amp;lt;`.
180#[must_use]
181pub fn esc(s: &str) -> String {
182    let mut out = String::with_capacity(s.len() + 8);
183    for c in s.chars() {
184        match c {
185            '&' => out.push_str("&amp;"),
186            '<' => out.push_str("&lt;"),
187            '>' => out.push_str("&gt;"),
188            '"' => out.push_str("&quot;"),
189            '\'' => out.push_str("&#x27;"),
190            _ => out.push(c),
191        }
192    }
193    out
194}
195
196/// Strip a filename down to a header-safe token: no quotes, no control
197/// chars, no path separators. Prevents header-injection and traversal in
198/// the `Content-Disposition` value.
199#[must_use]
200pub fn sanitize_filename(name: &str) -> String {
201    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
202    let cleaned: String = base
203        .chars()
204        .filter(|c| !c.is_control() && *c != '"' && *c != '\\')
205        .take(200)
206        .collect();
207    if cleaned.is_empty() {
208        "download".to_string()
209    } else {
210        cleaned
211    }
212}
213
214/// Heuristic: does a byte slice look like UTF-8 text safe to serve inline
215/// as `text/plain`? A NUL byte or invalid UTF-8 forces the attachment path.
216#[must_use]
217pub fn looks_textual(bytes: &[u8]) -> bool {
218    if bytes.contains(&0) {
219        return false;
220    }
221    std::str::from_utf8(bytes).is_ok()
222}
223
224/// Percent-decode a form/query token, mapping `+` to space and `%XX` to
225/// its byte. Invalid escapes are passed through literally.
226#[must_use]
227pub fn percent_decode(s: &str) -> String {
228    let bytes = s.as_bytes();
229    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
230    let mut i = 0;
231    while i < bytes.len() {
232        match bytes[i] {
233            b'+' => {
234                out.push(b' ');
235                i += 1;
236            }
237            b'%' if i + 2 < bytes.len() => {
238                let hi = (bytes[i + 1] as char).to_digit(16);
239                let lo = (bytes[i + 2] as char).to_digit(16);
240                match (hi, lo) {
241                    (Some(h), Some(l)) => {
242                        out.push((h * 16 + l) as u8);
243                        i += 3;
244                    }
245                    _ => {
246                        out.push(b'%');
247                        i += 1;
248                    }
249                }
250            }
251            b => {
252                out.push(b);
253                i += 1;
254            }
255        }
256    }
257    String::from_utf8_lossy(&out).into_owned()
258}
259
260/// Parse `application/x-www-form-urlencoded` body into decoded pairs.
261#[must_use]
262pub fn parse_form(body: &str) -> Vec<(String, String)> {
263    let mut out = Vec::new();
264    for pair in body.split('&') {
265        if pair.is_empty() {
266            continue;
267        }
268        let mut it = pair.splitn(2, '=');
269        let k = percent_decode(it.next().unwrap_or(""));
270        let v = percent_decode(it.next().unwrap_or(""));
271        out.push((k, v));
272    }
273    out
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn esc_neutralises_script_payload() {
282        let raw = r#"<script>alert('xss')</script>"#;
283        let out = esc(raw);
284        assert!(!out.contains('<'));
285        assert!(!out.contains('>'));
286        assert_eq!(out, "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;");
287    }
288
289    #[test]
290    fn esc_escapes_ampersand_once() {
291        assert_eq!(esc("a & b < c"), "a &amp; b &lt; c");
292        // No double-escaping: a literal `&lt;` in input becomes `&amp;lt;`.
293        assert_eq!(esc("&lt;"), "&amp;lt;");
294    }
295
296    #[test]
297    fn esc_escapes_quotes() {
298        assert_eq!(esc(r#"say "hi""#), "say &quot;hi&quot;");
299    }
300
301    #[test]
302    fn raw_bytes_binary_is_attachment() {
303        let r = ForgeResponse::raw_bytes(Bytes::from_static(&[0u8, 1, 2]), false, "evil.html");
304        let ct = r
305            .headers
306            .iter()
307            .find(|(k, _)| k == "content-type")
308            .map(|(_, v)| v.as_str())
309            .unwrap();
310        assert_eq!(ct, "application/octet-stream");
311        assert!(r
312            .headers
313            .iter()
314            .any(|(k, v)| k == "content-disposition" && v.contains("attachment")));
315    }
316
317    #[test]
318    fn raw_bytes_text_is_plain() {
319        let r = ForgeResponse::raw_bytes(Bytes::from_static(b"hello"), true, "a.txt");
320        let ct = r
321            .headers
322            .iter()
323            .find(|(k, _)| k == "content-type")
324            .map(|(_, v)| v.as_str())
325            .unwrap();
326        assert!(ct.starts_with("text/plain"));
327    }
328
329    #[test]
330    fn sanitize_filename_strips_path_and_quotes() {
331        assert_eq!(sanitize_filename("../../etc/pa\"ss"), "pass");
332        assert_eq!(sanitize_filename(""), "download");
333        assert_eq!(sanitize_filename("a/b/c.tar.gz"), "c.tar.gz");
334    }
335
336    #[test]
337    fn looks_textual_rejects_nul() {
338        assert!(looks_textual(b"plain text"));
339        assert!(!looks_textual(&[b'a', 0, b'b']));
340    }
341
342    #[test]
343    fn query_param_parses_first_and_plus() {
344        let req = ForgeRequest {
345            method: "GET".into(),
346            path: "/forge/a/b/issues".into(),
347            query: "state=open&q=hello+world".into(),
348            headers: vec![],
349            raw_body: Bytes::new(),
350            host_url: None,
351        };
352        assert_eq!(req.query_param("state").as_deref(), Some("open"));
353        assert_eq!(req.query_param("q").as_deref(), Some("hello world"));
354        assert_eq!(req.query_param("missing"), None);
355    }
356
357    #[test]
358    fn percent_decode_and_form() {
359        assert_eq!(percent_decode("a%2Fb+c"), "a/b c");
360        assert_eq!(percent_decode("%zz"), "%zz");
361        let pairs = parse_form("title=Fix+bug&resourceUrl=https%3A%2F%2Fp%2Fx.jsonld");
362        assert_eq!(pairs[0], ("title".into(), "Fix bug".into()));
363        assert_eq!(
364            pairs[1],
365            ("resourceUrl".into(), "https://p/x.jsonld".into())
366        );
367    }
368
369    #[test]
370    fn header_lookup_is_case_insensitive() {
371        let req = ForgeRequest {
372            method: "GET".into(),
373            path: "/".into(),
374            query: String::new(),
375            headers: vec![("Authorization".into(), "Nostr xyz".into())],
376            raw_body: Bytes::new(),
377            host_url: None,
378        };
379        assert_eq!(req.header("authorization"), Some("Nostr xyz"));
380        assert_eq!(req.header("AUTHORIZATION"), Some("Nostr xyz"));
381    }
382}