1use bytes::Bytes;
17
18#[derive(Debug, Clone)]
23pub struct ForgeRequest {
24 pub method: String,
26 pub path: String,
29 pub query: String,
31 pub headers: Vec<(String, String)>,
34 pub raw_body: Bytes,
37 pub host_url: Option<String>,
40}
41
42impl ForgeRequest {
43 #[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 #[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 #[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#[derive(Debug, Clone)]
85pub struct ForgeResponse {
86 pub status: u16,
88 pub headers: Vec<(String, String)>,
90 pub body: Bytes,
92}
93
94impl ForgeResponse {
95 #[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 #[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 #[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 #[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 ("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 #[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 #[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 #[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#[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("&"),
186 '<' => out.push_str("<"),
187 '>' => out.push_str(">"),
188 '"' => out.push_str("""),
189 '\'' => out.push_str("'"),
190 _ => out.push(c),
191 }
192 }
193 out
194}
195
196#[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#[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#[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#[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, "<script>alert('xss')</script>");
287 }
288
289 #[test]
290 fn esc_escapes_ampersand_once() {
291 assert_eq!(esc("a & b < c"), "a & b < c");
292 assert_eq!(esc("<"), "&lt;");
294 }
295
296 #[test]
297 fn esc_escapes_quotes() {
298 assert_eq!(esc(r#"say "hi""#), "say "hi"");
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}