1use actix_http::uri::{PathAndQuery, Uri};
4use actix_router::Url;
5use actix_service::{Service, Transform};
6#[expect(deprecated, reason = "preserve the public ready future type")]
7use actix_utils::future::{ready, Ready};
8use bytes::Bytes;
9#[cfg(feature = "unicode")]
10use regex::Regex;
11#[cfg(not(feature = "unicode"))]
12use regex_lite::Regex;
13
14use crate::{
15 service::{ServiceRequest, ServiceResponse},
16 Error,
17};
18
19fn build_byte_index_map(old_path: &str, new_path: &str) -> Vec<u16> {
20 let old_path = old_path.as_bytes();
21 let new_path = new_path.as_bytes();
22
23 let mut map = Vec::with_capacity(old_path.len() + 1);
24 map.push(0);
25
26 let mut old_idx = 0usize;
27 let mut new_idx = 0usize;
28
29 while old_idx < old_path.len() {
30 if new_idx < new_path.len() && old_path[old_idx] == new_path[new_idx] {
31 new_idx += 1;
32 }
33
34 old_idx += 1;
35 map.push(new_idx.min(u16::MAX as usize) as u16);
36 }
37
38 map
39}
40
41#[non_exhaustive]
45#[derive(Debug, Clone, Copy, Default)]
46pub enum TrailingSlash {
47 #[default]
51 Trim,
52
53 MergeOnly,
57
58 Always,
62}
63
64#[derive(Debug, Clone, Copy)]
111pub struct NormalizePath(TrailingSlash);
112
113impl Default for NormalizePath {
114 fn default() -> Self {
115 log::warn!(
116 "`NormalizePath::default()` is deprecated. The default trailing slash behavior changed \
117 in v4 from `Always` to `Trim`. Update your call to `NormalizePath::new(...)`."
118 );
119
120 Self(TrailingSlash::Trim)
121 }
122}
123
124impl NormalizePath {
125 pub fn new(trailing_slash_style: TrailingSlash) -> Self {
127 Self(trailing_slash_style)
128 }
129
130 pub fn trim() -> Self {
134 Self::new(TrailingSlash::Trim)
135 }
136}
137
138#[expect(deprecated, reason = "preserve the public ready future type")]
139impl<S, B> Transform<S, ServiceRequest> for NormalizePath
140where
141 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
142 S::Future: 'static,
143{
144 type Response = ServiceResponse<B>;
145 type Error = Error;
146 type Transform = NormalizePathNormalization<S>;
147 type InitError = ();
148 type Future = Ready<Result<Self::Transform, Self::InitError>>;
149
150 fn new_transform(&self, service: S) -> Self::Future {
151 ready(Ok(NormalizePathNormalization {
152 service,
153 merge_slash: Regex::new("//+").unwrap(),
154 trailing_slash_behavior: self.0,
155 }))
156 }
157}
158
159pub struct NormalizePathNormalization<S> {
160 service: S,
161 merge_slash: Regex,
162 trailing_slash_behavior: TrailingSlash,
163}
164
165impl<S, B> Service<ServiceRequest> for NormalizePathNormalization<S>
166where
167 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
168 S::Future: 'static,
169{
170 type Response = ServiceResponse<B>;
171 type Error = Error;
172 type Future = S::Future;
173
174 actix_service::forward_ready!(service);
175
176 fn call(&self, mut req: ServiceRequest) -> Self::Future {
177 let head = req.head_mut();
178
179 let original_path = head.uri.path();
180
181 if !original_path.is_empty() {
184 let path = match self.trailing_slash_behavior {
187 TrailingSlash::Always => format!("{}/", original_path),
188 TrailingSlash::MergeOnly => original_path.to_string(),
189 TrailingSlash::Trim => original_path.trim_end_matches('/').to_string(),
190 };
191
192 let path = self.merge_slash.replace_all(&path, "/");
194
195 let path = if path.is_empty() { "/" } else { path.as_ref() };
198
199 if path != original_path {
211 let reindex = build_byte_index_map(original_path, path);
212 let mut parts = head.uri.clone().into_parts();
213 let query = parts.path_and_query.as_ref().and_then(|pq| pq.query());
214
215 let path = match query {
216 Some(q) => Bytes::from(format!("{}?{}", path, q)),
217 None => Bytes::copy_from_slice(path.as_bytes()),
218 };
219 parts.path_and_query = Some(PathAndQuery::from_maybe_shared(path).unwrap());
220
221 let uri = Uri::from_parts(parts).unwrap();
222 req.match_info_mut()
223 .update_with_reindex(Url::new(uri.clone()), |idx| {
224 let idx = usize::from(idx).min(reindex.len() - 1);
225 reindex[idx]
226 });
227 req.head_mut().uri = uri;
228 }
229 }
230 self.service.call(req)
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use std::future::ready;
237
238 use actix_http::StatusCode;
239 use actix_service::IntoService;
240
241 use super::*;
242 use crate::{
243 guard::fn_guard,
244 test::{call_service, init_service, read_body, TestRequest},
245 web, App, HttpResponse,
246 };
247
248 #[actix_rt::test]
249 async fn test_wrap() {
250 let app = init_service(
251 App::new()
252 .wrap(NormalizePath::default())
253 .service(web::resource("/").to(HttpResponse::Ok))
254 .service(web::resource("/v1/something").to(HttpResponse::Ok))
255 .service(
256 web::resource("/v2/something")
257 .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
258 .to(HttpResponse::Ok),
259 ),
260 )
261 .await;
262
263 let test_uris = vec![
264 "/",
265 "/?query=test",
266 "///",
267 "/v1//something",
268 "/v1//something////",
269 "//v1/something",
270 "//v1//////something",
271 "/v2//something?query=test",
272 "/v2//something////?query=test",
273 "//v2/something?query=test",
274 "//v2//////something?query=test",
275 ];
276
277 for uri in test_uris {
278 let req = TestRequest::with_uri(uri).to_request();
279 let res = call_service(&app, req).await;
280 assert!(res.status().is_success(), "Failed uri: {}", uri);
281 }
282 }
283
284 #[actix_rt::test]
285 async fn trim_trailing_slashes() {
286 let app = init_service(
287 App::new()
288 .wrap(NormalizePath(TrailingSlash::Trim))
289 .service(web::resource("/").to(HttpResponse::Ok))
290 .service(web::resource("/v1/something").to(HttpResponse::Ok))
291 .service(
292 web::resource("/v2/something")
293 .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
294 .to(HttpResponse::Ok),
295 ),
296 )
297 .await;
298
299 let test_uris = vec![
300 "/",
301 "///",
302 "/v1/something",
303 "/v1/something/",
304 "/v1/something////",
305 "//v1//something",
306 "//v1//something//",
307 "/v2/something?query=test",
308 "/v2/something/?query=test",
309 "/v2/something////?query=test",
310 "//v2//something?query=test",
311 "//v2//something//?query=test",
312 ];
313
314 for uri in test_uris {
315 let req = TestRequest::with_uri(uri).to_request();
316 let res = call_service(&app, req).await;
317 assert!(res.status().is_success(), "Failed uri: {}", uri);
318 }
319 }
320
321 #[actix_rt::test]
322 async fn trim_root_trailing_slashes_with_query() {
323 let app = init_service(
324 App::new().wrap(NormalizePath(TrailingSlash::Trim)).service(
325 web::resource("/")
326 .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
327 .to(HttpResponse::Ok),
328 ),
329 )
330 .await;
331
332 let test_uris = vec!["/?query=test", "//?query=test", "///?query=test"];
333
334 for uri in test_uris {
335 let req = TestRequest::with_uri(uri).to_request();
336 let res = call_service(&app, req).await;
337 assert!(res.status().is_success(), "Failed uri: {}", uri);
338 }
339 }
340
341 #[actix_rt::test]
342 async fn ensure_trailing_slash() {
343 let app = init_service(
344 App::new()
345 .wrap(NormalizePath(TrailingSlash::Always))
346 .service(web::resource("/").to(HttpResponse::Ok))
347 .service(web::resource("/v1/something/").to(HttpResponse::Ok))
348 .service(
349 web::resource("/v2/something/")
350 .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
351 .to(HttpResponse::Ok),
352 ),
353 )
354 .await;
355
356 let test_uris = vec![
357 "/",
358 "///",
359 "/v1/something",
360 "/v1/something/",
361 "/v1/something////",
362 "//v1//something",
363 "//v1//something//",
364 "/v2/something?query=test",
365 "/v2/something/?query=test",
366 "/v2/something////?query=test",
367 "//v2//something?query=test",
368 "//v2//something//?query=test",
369 ];
370
371 for uri in test_uris {
372 let req = TestRequest::with_uri(uri).to_request();
373 let res = call_service(&app, req).await;
374 assert!(res.status().is_success(), "Failed uri: {}", uri);
375 }
376 }
377
378 #[actix_rt::test]
379 async fn ensure_root_trailing_slash_with_query() {
380 let app = init_service(
381 App::new()
382 .wrap(NormalizePath(TrailingSlash::Always))
383 .service(
384 web::resource("/")
385 .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
386 .to(HttpResponse::Ok),
387 ),
388 )
389 .await;
390
391 let test_uris = vec!["/?query=test", "//?query=test", "///?query=test"];
392
393 for uri in test_uris {
394 let req = TestRequest::with_uri(uri).to_request();
395 let res = call_service(&app, req).await;
396 assert!(res.status().is_success(), "Failed uri: {}", uri);
397 }
398 }
399
400 #[actix_rt::test]
401 async fn keep_trailing_slash_unchanged() {
402 let app = init_service(
403 App::new()
404 .wrap(NormalizePath(TrailingSlash::MergeOnly))
405 .service(web::resource("/").to(HttpResponse::Ok))
406 .service(web::resource("/v1/something").to(HttpResponse::Ok))
407 .service(web::resource("/v1/").to(HttpResponse::Ok))
408 .service(
409 web::resource("/v2/something")
410 .guard(fn_guard(|ctx| ctx.head().uri.query() == Some("query=test")))
411 .to(HttpResponse::Ok),
412 ),
413 )
414 .await;
415
416 let tests = vec![
417 ("/", true), ("/?query=test", true),
419 ("///", true),
420 ("/v1/something////", false),
421 ("/v1/something/", false),
422 ("//v1//something", true),
423 ("/v1/", true),
424 ("/v1", false),
425 ("/v1////", true),
426 ("//v1//", true),
427 ("///v1", false),
428 ("/v2/something?query=test", true),
429 ("/v2/something/?query=test", false),
430 ("/v2/something//?query=test", false),
431 ("//v2//something?query=test", true),
432 ];
433
434 for (uri, success) in tests {
435 let req = TestRequest::with_uri(uri).to_request();
436 let res = call_service(&app, req).await;
437 assert_eq!(res.status().is_success(), success, "Failed uri: {}", uri);
438 }
439 }
440
441 #[actix_rt::test]
442 async fn scope_dynamic_tail_path_is_reindexed() {
443 async fn handler(path: web::Path<String>) -> HttpResponse {
444 HttpResponse::Ok().body(path.into_inner())
445 }
446
447 let app = init_service(
448 App::new().service(
449 web::scope("{tail:.*}")
450 .wrap(NormalizePath::trim())
451 .default_service(web::to(handler)),
452 ),
453 )
454 .await;
455
456 let req = TestRequest::with_uri("/uaie//iuaei").to_request();
457 let res = call_service(&app, req).await;
458
459 assert_eq!(res.status(), StatusCode::OK);
460 assert_eq!(read_body(res).await, Bytes::from_static(b"uaie/iuaei"));
461 }
462
463 #[actix_rt::test]
464 async fn scope_static_prefix_skip_is_reindexed() {
465 let app = init_service(
466 App::new().service(
467 web::scope("/api")
468 .wrap(NormalizePath::trim())
469 .service(web::resource("/v1").to(HttpResponse::Ok)),
470 ),
471 )
472 .await;
473
474 let req = TestRequest::with_uri("/api//v1").to_request();
475 let res = call_service(&app, req).await;
476
477 assert_eq!(res.status(), StatusCode::OK);
478 }
479
480 #[actix_rt::test]
481 async fn no_path() {
482 let app = init_service(
483 App::new()
484 .wrap(NormalizePath::default())
485 .service(web::resource("/").to(HttpResponse::Ok)),
486 )
487 .await;
488
489 let req = TestRequest::with_uri("eh").to_request();
492 let res = call_service(&app, req).await;
493 assert_eq!(res.status(), StatusCode::NOT_FOUND);
494 }
495
496 #[actix_rt::test]
497 async fn test_in_place_normalization() {
498 let srv = |req: ServiceRequest| {
499 assert_eq!("/v1/something", req.path());
500 ready(Ok(req.into_response(HttpResponse::Ok().finish())))
501 };
502
503 let normalize = NormalizePath::default()
504 .new_transform(srv.into_service())
505 .await
506 .unwrap();
507
508 let test_uris = vec![
509 "/v1//something////",
510 "///v1/something",
511 "//v1///something",
512 "/v1//something",
513 ];
514
515 for uri in test_uris {
516 let req = TestRequest::with_uri(uri).to_srv_request();
517 let res = normalize.call(req).await.unwrap();
518 assert!(res.status().is_success(), "Failed uri: {}", uri);
519 }
520 }
521
522 #[actix_rt::test]
523 async fn should_normalize_nothing() {
524 const URI: &str = "/v1/something";
525
526 let srv = |req: ServiceRequest| {
527 assert_eq!(URI, req.path());
528 ready(Ok(req.into_response(HttpResponse::Ok().finish())))
529 };
530
531 let normalize = NormalizePath::default()
532 .new_transform(srv.into_service())
533 .await
534 .unwrap();
535
536 let req = TestRequest::with_uri(URI).to_srv_request();
537 let res = normalize.call(req).await.unwrap();
538 assert!(res.status().is_success());
539 }
540
541 #[actix_rt::test]
542 async fn should_normalize_no_trail() {
543 let srv = |req: ServiceRequest| {
544 assert_eq!("/v1/something", req.path());
545 ready(Ok(req.into_response(HttpResponse::Ok().finish())))
546 };
547
548 let normalize = NormalizePath::default()
549 .new_transform(srv.into_service())
550 .await
551 .unwrap();
552
553 let req = TestRequest::with_uri("/v1/something/").to_srv_request();
554 let res = normalize.call(req).await.unwrap();
555 assert!(res.status().is_success());
556 }
557}