1use headers::HeaderValue;
22use hyper::{Request, Response, StatusCode};
23use regex_lite::Regex;
24
25use crate::body::Body;
26use crate::{Error, error_page, handler::RequestHandlerOpts, settings::Redirects};
27
28pub(crate) const MAX_URI_LEN_FOR_REGEX: usize = 8 * 1024;
35
36pub(crate) fn pre_process<T>(
38 opts: &RequestHandlerOpts,
39 req: &Request<T>,
40) -> Option<Result<Response<Body>, Error>> {
41 let redirects = opts.advanced_opts.as_ref()?.redirects.as_deref()?;
42
43 let uri = req.uri();
44 let uri_path = uri.path();
45 if uri_path.len() > MAX_URI_LEN_FOR_REGEX {
48 tracing::debug!(
49 "redirects: skipping match, uri path length {} exceeds cap {}",
50 uri_path.len(),
51 MAX_URI_LEN_FOR_REGEX
52 );
53 return None;
54 }
55 let host = req
56 .headers()
57 .get(http::header::HOST)
58 .and_then(|v| v.to_str().ok())
59 .unwrap_or("");
60 let mut uri_host = uri.host().unwrap_or(host).to_owned();
61 if let Some(uri_port) = uri.port_u16() {
62 uri_host.push_str(&format!(":{uri_port}"));
63 }
64 let matched = get_redirection(&uri_host, uri_path, Some(redirects))?;
65 let mut dest = match replace_placeholders(
66 uri_path,
67 &matched.source,
68 &matched.destination,
69 &matched.replacer,
70 ) {
71 Ok(dest) => dest,
72 Err(err) => return handle_error(err, opts, req),
73 };
74
75 if let Some(query) = uri.query() {
78 if !dest.ends_with('?') && !dest.ends_with('&') {
79 dest.push(if dest.contains('?') { '&' } else { '?' });
80 }
81 dest.push_str(query);
82 }
83
84 match HeaderValue::from_str(&dest) {
85 Ok(loc) => {
86 let mut resp = Response::new(crate::body::empty());
87 resp.headers_mut().insert(hyper::header::LOCATION, loc);
88 *resp.status_mut() = matched.kind;
89 tracing::trace!(
90 "uri matches redirects glob pattern, redirecting with status '{}'",
91 matched.kind
92 );
93 Some(Ok(resp))
94 }
95 Err(err) => handle_error(
96 Error::new(err).context("invalid header value from current uri"),
97 opts,
98 req,
99 ),
100 }
101}
102
103#[doc(hidden)]
105pub fn replace_placeholders(
106 orig_uri: &str,
107 regex: &Regex,
108 dest_uri: &str,
109 ac: &aho_corasick::AhoCorasick,
110) -> Result<String, Error> {
111 let regex_caps = if let Some(regex_caps) = regex.captures(orig_uri) {
112 regex_caps
113 } else {
114 return Err(Error::msg("regex didn't match, extracting captures failed"));
115 };
116
117 let caps: Vec<&str> = (0..regex_caps.len())
118 .map(|i| regex_caps.get(i).map(|s| s.as_str()).unwrap_or(""))
119 .collect();
120
121 tracing::debug!("url redirects/rewrites regex equivalent: {regex}");
122 tracing::debug!("url redirects/rewrites glob pattern captures: {caps:?}");
123 tracing::debug!("url redirects/rewrites glob pattern destination: {dest_uri:?}");
124
125 match ac.try_replace_all(dest_uri, &caps) {
126 Ok(dest) => {
127 tracing::debug!("url redirects/rewrites glob pattern destination replaced: {dest:?}");
128 Ok(dest)
129 }
130 Err(err) => Err(Error::new(err).context("failed replacing captures")),
131 }
132}
133
134pub(crate) fn handle_error<T>(
136 err: Error,
137 opts: &RequestHandlerOpts,
138 req: &Request<T>,
139) -> Option<Result<Response<Body>, Error>> {
140 tracing::error!("{err:?}");
141 Some(error_page::error_response(
142 req.uri(),
143 req.method(),
144 &StatusCode::INTERNAL_SERVER_ERROR,
145 &opts.page404,
146 &opts.page50x,
147 ))
148}
149
150pub fn get_redirection<'a>(
153 uri_host: &'a str,
154 uri_path: &'a str,
155 redirects_opts: Option<&'a [Redirects]>,
156) -> Option<&'a Redirects> {
157 if let Some(redirects_vec) = redirects_opts {
158 for redirect_entry in redirects_vec {
159 if let Some(host) = &redirect_entry.host {
161 tracing::debug!(
162 "checking host '{host}' redirect entry against uri host '{uri_host}'"
163 );
164 if !host.eq(uri_host) {
165 continue;
166 }
167 }
168
169 if redirect_entry.source.is_match(uri_path) {
171 return Some(redirect_entry);
172 }
173 }
174 }
175
176 None
177}
178
179#[cfg(test)]
180mod tests {
181 use super::pre_process;
182 use crate::body::Body;
183 use crate::{
184 Error,
185 handler::RequestHandlerOpts,
186 settings::{Advanced, Redirects, build_placeholder_replacer},
187 };
188 use hyper::{Request, Response, StatusCode};
189 use regex_lite::Regex;
190
191 fn make_request(host: &str, uri: &str) -> Request<Body> {
192 let mut builder = Request::builder();
193 if !host.is_empty() {
194 builder = builder.header("Host", host);
195 }
196 builder
197 .method("GET")
198 .uri(uri)
199 .body(crate::body::empty())
200 .unwrap()
201 }
202
203 fn get_redirects() -> Vec<Redirects> {
204 let s1 = Regex::new(r"/source1$").unwrap();
205 let r1 = build_placeholder_replacer(&s1);
206 let s2 = Regex::new(r"/source2$").unwrap();
207 let r2 = build_placeholder_replacer(&s2);
208 let s3 = Regex::new(r"/(prefix/)?(source3)/(.*)").unwrap();
209 let r3 = build_placeholder_replacer(&s3);
210 let s4 = Regex::new(r"/source4/(.*)").unwrap();
211 let r4 = build_placeholder_replacer(&s4);
212 vec![
213 Redirects {
214 host: None,
215 source: s1,
216 destination: "/destination1".into(),
217 kind: StatusCode::FOUND,
218 replacer: r1,
219 },
220 Redirects {
221 host: Some("example.com".into()),
222 source: s2,
223 destination: "/destination2".into(),
224 kind: StatusCode::MOVED_PERMANENTLY,
225 replacer: r2,
226 },
227 Redirects {
228 host: Some("example.info".into()),
229 source: s3,
230 destination: "/destination3/$2/$3".into(),
231 kind: StatusCode::MOVED_PERMANENTLY,
232 replacer: r3,
233 },
234 Redirects {
235 host: None,
236 source: s4,
237 destination: "/destination4?p=$1".into(),
238 kind: StatusCode::FOUND,
239 replacer: r4,
240 },
241 ]
242 }
243
244 fn is_redirect(result: Option<Result<Response<Body>, Error>>) -> Option<(StatusCode, String)> {
245 if let Some(Ok(response)) = result {
246 let location = response.headers().get("Location")?.to_str().unwrap().into();
247 Some((response.status(), location))
248 } else {
249 None
250 }
251 }
252
253 #[test]
254 fn test_no_redirects() {
255 assert!(
256 pre_process(
257 &RequestHandlerOpts {
258 advanced_opts: None,
259 ..Default::default()
260 },
261 &make_request("", "/")
262 )
263 .is_none()
264 );
265
266 assert!(
267 pre_process(
268 &RequestHandlerOpts {
269 advanced_opts: Some(Advanced {
270 redirects: None,
271 ..Default::default()
272 }),
273 ..Default::default()
274 },
275 &make_request("", "/")
276 )
277 .is_none()
278 );
279 }
280
281 #[test]
282 fn test_no_match() {
283 assert!(
284 pre_process(
285 &RequestHandlerOpts {
286 advanced_opts: Some(Advanced {
287 redirects: Some(get_redirects()),
288 ..Default::default()
289 }),
290 ..Default::default()
291 },
292 &make_request("example.com", "/source2/whatever")
293 )
294 .is_none()
295 );
296
297 assert!(
298 pre_process(
299 &RequestHandlerOpts {
300 advanced_opts: Some(Advanced {
301 redirects: Some(get_redirects()),
302 ..Default::default()
303 }),
304 ..Default::default()
305 },
306 &make_request("", "/source2")
307 )
308 .is_none()
309 );
310 }
311
312 #[test]
313 fn test_match() {
314 assert_eq!(
315 is_redirect(pre_process(
316 &RequestHandlerOpts {
317 advanced_opts: Some(Advanced {
318 redirects: Some(get_redirects()),
319 ..Default::default()
320 }),
321 ..Default::default()
322 },
323 &make_request("", "/source1")
324 )),
325 Some((StatusCode::FOUND, "/destination1".into()))
326 );
327
328 assert_eq!(
329 is_redirect(pre_process(
330 &RequestHandlerOpts {
331 advanced_opts: Some(Advanced {
332 redirects: Some(get_redirects()),
333 ..Default::default()
334 }),
335 ..Default::default()
336 },
337 &make_request("example.com", "/source2")
338 )),
339 Some((StatusCode::MOVED_PERMANENTLY, "/destination2".into()))
340 );
341
342 assert_eq!(
343 is_redirect(pre_process(
344 &RequestHandlerOpts {
345 advanced_opts: Some(Advanced {
346 redirects: Some(get_redirects()),
347 ..Default::default()
348 }),
349 ..Default::default()
350 },
351 &make_request("example.info", "/source3/whatever")
352 )),
353 Some((
354 StatusCode::MOVED_PERMANENTLY,
355 "/destination3/source3/whatever".into()
356 ))
357 );
358
359 assert_eq!(
360 is_redirect(pre_process(
361 &RequestHandlerOpts {
362 advanced_opts: Some(Advanced {
363 redirects: Some(get_redirects()),
364 ..Default::default()
365 }),
366 ..Default::default()
367 },
368 &make_request("", "/source4/whatever")
369 )),
370 Some((StatusCode::FOUND, "/destination4?p=whatever".into()))
371 );
372 }
373
374 #[test]
375 fn test_query() {
376 assert_eq!(
377 is_redirect(pre_process(
378 &RequestHandlerOpts {
379 advanced_opts: Some(Advanced {
380 redirects: Some(get_redirects()),
381 ..Default::default()
382 }),
383 ..Default::default()
384 },
385 &make_request("", "/source1?q=query-string")
386 )),
387 Some((StatusCode::FOUND, "/destination1?q=query-string".into()))
388 );
389
390 assert_eq!(
391 is_redirect(pre_process(
392 &RequestHandlerOpts {
393 advanced_opts: Some(Advanced {
394 redirects: Some(get_redirects()),
395 ..Default::default()
396 }),
397 ..Default::default()
398 },
399 &make_request("example.com", "/source2?q=query-string")
400 )),
401 Some((
402 StatusCode::MOVED_PERMANENTLY,
403 "/destination2?q=query-string".into()
404 ))
405 );
406
407 assert_eq!(
408 is_redirect(pre_process(
409 &RequestHandlerOpts {
410 advanced_opts: Some(Advanced {
411 redirects: Some(get_redirects()),
412 ..Default::default()
413 }),
414 ..Default::default()
415 },
416 &make_request("example.info", "/source3/whatever?q=query-string")
417 )),
418 Some((
419 StatusCode::MOVED_PERMANENTLY,
420 "/destination3/source3/whatever?q=query-string".into()
421 ))
422 );
423
424 assert_eq!(
425 is_redirect(pre_process(
426 &RequestHandlerOpts {
427 advanced_opts: Some(Advanced {
428 redirects: Some(get_redirects()),
429 ..Default::default()
430 }),
431 ..Default::default()
432 },
433 &make_request("", "/source4/whatever?q=query-string")
434 )),
435 Some((
436 StatusCode::FOUND,
437 "/destination4?p=whatever&q=query-string".into()
438 ))
439 );
440 }
441
442 use super::{MAX_URI_LEN_FOR_REGEX, replace_placeholders};
450 use proptest::prelude::*;
451
452 proptest! {
453 #![proptest_config(ProptestConfig {
454 cases: 128, ..ProptestConfig::default()
455 })]
456
457 #[test]
461 fn prop_replace_placeholders_never_panics(
462 orig in "\\PC{0,512}",
463 dest in "\\PC{0,512}",
464 ) {
465 let re = Regex::new(r"^/(.*)/(.*)/(.*)/(.*)/(.*)$").unwrap();
468 let ac = build_placeholder_replacer(&re);
469 let _ = replace_placeholders(&orig, &re, &dest, &ac);
470 }
471
472 #[test]
475 fn prop_replace_placeholders_no_match_returns_err(
476 tail in "[a-zA-Z0-9_.-]{0,64}",
478 dest in "\\PC{0,64}",
479 ) {
480 let orig = format!("no-leading-slash-{tail}");
481 let re = Regex::new(r"^/(.*)/(.*)/(.*)/(.*)/(.*)$").unwrap();
482 let ac = build_placeholder_replacer(&re);
483 prop_assert!(replace_placeholders(&orig, &re, &dest, &ac).is_err());
484 }
485
486 #[test]
490 fn prop_replace_placeholders_substitutes_all_indices(
491 a in "[a-zA-Z0-9]{1,16}",
492 b in "[a-zA-Z0-9]{1,16}",
493 c in "[a-zA-Z0-9]{1,16}",
494 d in "[a-zA-Z0-9]{1,16}",
495 e in "[a-zA-Z0-9]{1,16}",
496 ) {
497 let orig = format!("/{a}/{b}/{c}/{d}/{e}");
498 let re = Regex::new(r"^/(.*)/(.*)/(.*)/(.*)/(.*)$").unwrap();
499 let ac = build_placeholder_replacer(&re);
500 let dest = "/$0|$1|$2|$3|$4|$5".to_string();
501 let out = replace_placeholders(&orig, &re, &dest, &ac).unwrap();
502 let expected = format!("/{orig}|{a}|{b}|{c}|{d}|{e}");
504 prop_assert_eq!(out, expected);
505 }
506 }
507
508 #[test]
511 fn max_uri_len_for_regex_is_at_least_8kib() {
512 const { assert!(MAX_URI_LEN_FOR_REGEX >= 8 * 1024) };
513 }
514}