1use actix_web::{
54 Error, HttpResponse,
55 body::{BoxBody, MessageBody, to_bytes},
56 dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
57 http::{Method, StatusCode, header},
58 web::Bytes,
59};
60use futures_util::future::{LocalBoxFuture, Ready, ok};
61use std::rc::Rc;
62
63use base64::Engine;
64use xxhash_rust::xxh3::xxh3_128;
65
66#[derive(Clone, Copy)]
71pub struct ETag {
72 strength: Strength,
73}
74
75#[derive(Clone, Copy)]
76enum Strength {
77 Strong,
78 Weak,
79}
80
81impl ETag {
82 pub const fn new() -> Self {
84 Self::strong()
85 }
86
87 pub const fn strong() -> Self {
89 Self {
90 strength: Strength::Strong,
91 }
92 }
93
94 pub const fn weak() -> Self {
97 Self {
98 strength: Strength::Weak,
99 }
100 }
101}
102
103impl Default for ETag {
104 fn default() -> Self {
105 Self::strong()
106 }
107}
108
109impl<S, B> Transform<S, ServiceRequest> for ETag
110where
111 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
112 B: MessageBody + 'static,
113 B::Error: Into<Error>,
114{
115 type Response = ServiceResponse<BoxBody>;
116 type Error = Error;
117 type InitError = ();
118 type Transform = ETagMiddleware<S>;
119 type Future = Ready<Result<Self::Transform, Self::InitError>>;
120
121 fn new_transform(&self, service: S) -> Self::Future {
122 ok(ETagMiddleware {
123 service: Rc::new(service),
124 strength: self.strength,
125 })
126 }
127}
128
129pub struct ETagMiddleware<S> {
131 service: Rc<S>,
132 strength: Strength,
133}
134
135impl<S, B> Service<ServiceRequest> for ETagMiddleware<S>
136where
137 S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
138 B: MessageBody + 'static,
139 B::Error: Into<Error>,
140{
141 type Response = ServiceResponse<BoxBody>;
142 type Error = Error;
143 type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
144
145 forward_ready!(service);
146
147 fn call(&self, req: ServiceRequest) -> Self::Future {
148 let srv = Rc::clone(&self.service);
149 let strength = self.strength;
150
151 Box::pin(async move {
152 let res = srv.call(req).await?;
153 let (req, res) = res.into_parts();
154 let (mut head, body) = res.into_parts();
155 let body_bytes = to_bytes(body).await.map_err(Into::into)?;
156
157 let etag_value = extract_or_compute_etag(&mut head, &body_bytes, strength);
158
159 if let Some(precondition) = evaluate_conditionals(&req, &etag_value) {
160 return Ok(ServiceResponse::new(req, precondition));
161 }
162
163 let response = head.set_body(body_bytes).map_body(|_, body| body.boxed());
164
165 Ok(ServiceResponse::new(req, response))
166 })
167 }
168}
169
170fn extract_or_compute_etag(
171 head: &mut HttpResponse<()>,
172 body: &Bytes,
173 strength: Strength,
174) -> String {
175 if let Some(value) = head
176 .headers()
177 .get(header::ETAG)
178 .and_then(|value| value.to_str().ok())
179 {
180 return value.trim().to_string();
181 }
182
183 let value = build_entity_tag(body, strength);
184
185 if let Ok(header_value) = header::HeaderValue::from_str(&value) {
186 head.headers_mut().insert(header::ETAG, header_value);
187 }
188
189 value
190}
191
192fn evaluate_conditionals(req: &actix_web::HttpRequest, etag: &str) -> Option<HttpResponse> {
195 if let Some(if_match) = req
196 .headers()
197 .get(header::IF_MATCH)
198 .and_then(|h| h.to_str().ok())
199 {
200 if !match_if_match(etag, if_match) {
201 return Some(
202 HttpResponse::build(StatusCode::PRECONDITION_FAILED)
203 .insert_header((header::ETAG, etag.to_string()))
204 .finish(),
205 );
206 }
207 }
208
209 if let Some(if_none_match) = req
210 .headers()
211 .get(header::IF_NONE_MATCH)
212 .and_then(|h| h.to_str().ok())
213 {
214 if match_if_none_match(etag, if_none_match) {
215 let status = match *req.method() {
216 Method::GET | Method::HEAD => StatusCode::NOT_MODIFIED,
217 _ => StatusCode::PRECONDITION_FAILED,
218 };
219
220 return Some(
221 HttpResponse::build(status)
222 .insert_header((header::ETAG, etag.to_string()))
223 .finish(),
224 );
225 }
226 }
227
228 None
229}
230
231fn match_if_match(etag: &str, header_value: &str) -> bool {
232 header_value
233 .split(',')
234 .map(|value| value.trim())
235 .any(|value| value == "*" || strong_compare(value, etag))
236}
237
238fn match_if_none_match(etag: &str, header_value: &str) -> bool {
239 let etag_core = strip_weak_prefix(etag);
240
241 header_value
242 .split(',')
243 .map(|value| value.trim())
244 .any(|value| {
245 if value == "*" {
246 return true;
247 }
248
249 strip_weak_prefix(value) == etag_core
250 })
251}
252
253fn build_entity_tag(body: &Bytes, strength: Strength) -> String {
254 let response_hash = xxh3_128(body);
255 let base64 = base64::prelude::BASE64_URL_SAFE.encode(response_hash.to_le_bytes());
256
257 match strength {
258 Strength::Strong => format!("\"{:x}-{}\"", base64.len(), base64),
259 Strength::Weak => format!("W/\"{:x}-{}\"", base64.len(), base64),
260 }
261}
262
263fn strong_compare(left: &str, right: &str) -> bool {
264 !is_weak(left) && !is_weak(right) && left == right
265}
266
267fn strip_weak_prefix(value: &str) -> &str {
268 value.strip_prefix("W/").unwrap_or(value)
269}
270
271fn is_weak(value: &str) -> bool {
272 value.starts_with("W/")
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use actix_web::{
279 App, HttpResponse,
280 dev::ServiceResponse,
281 http::header,
282 test::{TestRequest, call_service, init_service},
283 web,
284 };
285
286 fn expected_etag(payload: &[u8], strength: Strength) -> String {
287 let bytes = Bytes::copy_from_slice(payload);
288 build_entity_tag(&bytes, strength)
289 }
290
291 #[actix_web::test]
292 async fn sets_etag_header_when_missing() {
293 let app = init_service(App::new().wrap(ETag::strong()).route(
294 "/",
295 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
296 ))
297 .await;
298
299 let response: ServiceResponse =
300 call_service(&app, TestRequest::get().uri("/").to_request()).await;
301
302 assert_eq!(response.status(), StatusCode::OK);
303 let value = response.headers().get(header::ETAG).unwrap();
304 assert_eq!(
305 value.to_str().unwrap(),
306 expected_etag(b"hello", Strength::Strong)
307 );
308 }
309
310 #[actix_web::test]
311 async fn returns_not_modified_for_matching_if_none_match() {
312 let etag = expected_etag(b"hello", Strength::Strong);
313 let app = init_service(App::new().wrap(ETag::strong()).route(
314 "/",
315 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
316 ))
317 .await;
318
319 let request = TestRequest::get()
320 .uri("/")
321 .insert_header((header::IF_NONE_MATCH, etag.clone()))
322 .to_request();
323 let response: ServiceResponse = call_service(&app, request).await;
324
325 assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
326 assert_eq!(
327 response
328 .headers()
329 .get(header::ETAG)
330 .unwrap()
331 .to_str()
332 .unwrap(),
333 etag
334 );
335 }
336
337 #[actix_web::test]
338 async fn returns_precondition_failed_for_non_matching_if_match() {
339 let app = init_service(App::new().wrap(ETag::strong()).route(
340 "/",
341 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
342 ))
343 .await;
344
345 let request = TestRequest::get()
346 .uri("/")
347 .insert_header((header::IF_MATCH, "\"deadbeef\""))
348 .to_request();
349 let response: ServiceResponse = call_service(&app, request).await;
350
351 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
352 }
353
354 #[actix_web::test]
355 async fn allows_if_match_when_strong_tag_matches() {
356 let body = b"hello";
357 let expected = expected_etag(body, Strength::Strong);
358 let app = init_service(App::new().wrap(ETag::strong()).route(
359 "/",
360 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
361 ))
362 .await;
363
364 let request = TestRequest::get()
365 .uri("/")
366 .insert_header((header::IF_MATCH, expected.clone()))
367 .to_request();
368 let response: ServiceResponse = call_service(&app, request).await;
369
370 assert_eq!(response.status(), StatusCode::OK);
371 assert_eq!(
372 response
373 .headers()
374 .get(header::ETAG)
375 .unwrap()
376 .to_str()
377 .unwrap(),
378 expected
379 );
380 }
381
382 #[actix_web::test]
383 async fn sets_weak_etag_header_when_configured() {
384 let app = init_service(App::new().wrap(ETag::weak()).route(
385 "/",
386 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
387 ))
388 .await;
389
390 let response: ServiceResponse =
391 call_service(&app, TestRequest::get().uri("/").to_request()).await;
392
393 assert_eq!(response.status(), StatusCode::OK);
394 assert_eq!(
395 response
396 .headers()
397 .get(header::ETAG)
398 .unwrap()
399 .to_str()
400 .unwrap(),
401 expected_etag(b"hello", Strength::Weak)
402 );
403 }
404
405 #[actix_web::test]
406 async fn weak_etag_triggers_not_modified_with_strong_if_none_match() {
407 let etag = expected_etag(b"hello", Strength::Weak);
408 let app = init_service(App::new().wrap(ETag::weak()).route(
409 "/",
410 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
411 ))
412 .await;
413
414 let request = TestRequest::get()
415 .uri("/")
416 .insert_header((header::IF_NONE_MATCH, etag.trim_start_matches("W/")))
417 .to_request();
418 let response: ServiceResponse = call_service(&app, request).await;
419
420 assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
421 assert_eq!(
422 response
423 .headers()
424 .get(header::ETAG)
425 .unwrap()
426 .to_str()
427 .unwrap(),
428 etag
429 );
430 }
431
432 #[actix_web::test]
433 async fn weak_etag_fails_if_match_even_when_value_matches() {
434 let etag = expected_etag(b"hello", Strength::Weak);
435 let app = init_service(App::new().wrap(ETag::weak()).route(
436 "/",
437 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
438 ))
439 .await;
440
441 let request = TestRequest::get()
442 .uri("/")
443 .insert_header((header::IF_MATCH, etag.clone()))
444 .to_request();
445 let response: ServiceResponse = call_service(&app, request).await;
446
447 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
448 }
449
450 #[actix_web::test]
451 async fn new_method_creates_strong_etag_middleware() {
452 let app = init_service(App::new().wrap(ETag::new()).route(
453 "/",
454 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
455 ))
456 .await;
457
458 let response: ServiceResponse =
459 call_service(&app, TestRequest::get().uri("/").to_request()).await;
460
461 assert_eq!(response.status(), StatusCode::OK);
462 let value = response.headers().get(header::ETAG).unwrap();
463 assert_eq!(
464 value.to_str().unwrap(),
465 expected_etag(b"hello", Strength::Strong)
466 );
467 assert!(!value.to_str().unwrap().starts_with("W/"));
468 }
469
470 #[actix_web::test]
471 async fn default_trait_creates_strong_etag_middleware() {
472 let app = init_service(App::new().wrap(ETag::default()).route(
473 "/",
474 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
475 ))
476 .await;
477
478 let response: ServiceResponse =
479 call_service(&app, TestRequest::get().uri("/").to_request()).await;
480
481 assert_eq!(response.status(), StatusCode::OK);
482 let value = response.headers().get(header::ETAG).unwrap();
483 assert_eq!(
484 value.to_str().unwrap(),
485 expected_etag(b"hello", Strength::Strong)
486 );
487 assert!(!value.to_str().unwrap().starts_with("W/"));
488 }
489
490 #[actix_web::test]
491 async fn if_none_match_with_post_returns_precondition_failed() {
492 let etag = expected_etag(b"hello", Strength::Strong);
493 let app = init_service(App::new().wrap(ETag::strong()).route(
494 "/",
495 web::post().to(|| async { HttpResponse::Ok().body("hello") }),
496 ))
497 .await;
498
499 let request = TestRequest::post()
500 .uri("/")
501 .insert_header((header::IF_NONE_MATCH, etag.clone()))
502 .to_request();
503 let response: ServiceResponse = call_service(&app, request).await;
504
505 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
506 assert_eq!(
507 response
508 .headers()
509 .get(header::ETAG)
510 .unwrap()
511 .to_str()
512 .unwrap(),
513 etag
514 );
515 }
516
517 #[actix_web::test]
518 async fn if_none_match_with_put_returns_precondition_failed() {
519 let etag = expected_etag(b"hello", Strength::Strong);
520 let app = init_service(App::new().wrap(ETag::strong()).route(
521 "/",
522 web::put().to(|| async { HttpResponse::Ok().body("hello") }),
523 ))
524 .await;
525
526 let request = TestRequest::put()
527 .uri("/")
528 .insert_header((header::IF_NONE_MATCH, etag.clone()))
529 .to_request();
530 let response: ServiceResponse = call_service(&app, request).await;
531
532 assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED);
533 }
534
535 #[actix_web::test]
536 async fn if_none_match_with_wildcard_matches_any_etag() {
537 let app = init_service(App::new().wrap(ETag::strong()).route(
538 "/",
539 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
540 ))
541 .await;
542
543 let request = TestRequest::get()
544 .uri("/")
545 .insert_header((header::IF_NONE_MATCH, "*"))
546 .to_request();
547 let response: ServiceResponse = call_service(&app, request).await;
548
549 assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
550 assert!(response.headers().contains_key(header::ETAG));
551 }
552
553 #[actix_web::test]
554 async fn if_match_with_wildcard_matches_any_etag() {
555 let app = init_service(App::new().wrap(ETag::strong()).route(
556 "/",
557 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
558 ))
559 .await;
560
561 let request = TestRequest::get()
562 .uri("/")
563 .insert_header((header::IF_MATCH, "*"))
564 .to_request();
565 let response: ServiceResponse = call_service(&app, request).await;
566
567 assert_eq!(response.status(), StatusCode::OK);
568 assert!(response.headers().contains_key(header::ETAG));
569 }
570
571 #[actix_web::test]
572 async fn preserves_handler_set_etag() {
573 let custom_etag = "\"custom-etag-12345\"";
574 let app = init_service(App::new().wrap(ETag::strong()).route(
575 "/",
576 web::get().to(|| async {
577 HttpResponse::Ok()
578 .insert_header((header::ETAG, "\"custom-etag-12345\""))
579 .body("hello")
580 }),
581 ))
582 .await;
583
584 let response: ServiceResponse =
585 call_service(&app, TestRequest::get().uri("/").to_request()).await;
586
587 assert_eq!(response.status(), StatusCode::OK);
588 assert_eq!(
589 response
590 .headers()
591 .get(header::ETAG)
592 .unwrap()
593 .to_str()
594 .unwrap(),
595 custom_etag
596 );
597 }
598
599 #[actix_web::test]
600 async fn if_none_match_matches_handler_set_etag() {
601 let custom_etag = "\"custom-etag-12345\"";
602 let app = init_service(App::new().wrap(ETag::strong()).route(
603 "/",
604 web::get().to(|| async {
605 HttpResponse::Ok()
606 .insert_header((header::ETAG, "\"custom-etag-12345\""))
607 .body("hello")
608 }),
609 ))
610 .await;
611
612 let request = TestRequest::get()
613 .uri("/")
614 .insert_header((header::IF_NONE_MATCH, custom_etag))
615 .to_request();
616 let response: ServiceResponse = call_service(&app, request).await;
617
618 assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
619 }
620
621 #[actix_web::test]
622 async fn multiple_etags_in_if_none_match() {
623 let etag = expected_etag(b"hello", Strength::Strong);
624 let app = init_service(App::new().wrap(ETag::strong()).route(
625 "/",
626 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
627 ))
628 .await;
629
630 let multiple_etags = format!("\"other-etag\", {}, \"another-etag\"", etag);
631 let request = TestRequest::get()
632 .uri("/")
633 .insert_header((header::IF_NONE_MATCH, multiple_etags))
634 .to_request();
635 let response: ServiceResponse = call_service(&app, request).await;
636
637 assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
638 }
639
640 #[actix_web::test]
641 async fn multiple_etags_in_if_match_with_match() {
642 let etag = expected_etag(b"hello", Strength::Strong);
643 let app = init_service(App::new().wrap(ETag::strong()).route(
644 "/",
645 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
646 ))
647 .await;
648
649 let multiple_etags = format!("\"other-etag\", {}, \"another-etag\"", etag);
650 let request = TestRequest::get()
651 .uri("/")
652 .insert_header((header::IF_MATCH, multiple_etags))
653 .to_request();
654 let response: ServiceResponse = call_service(&app, request).await;
655
656 assert_eq!(response.status(), StatusCode::OK);
657 }
658
659 #[actix_web::test]
660 async fn response_without_precondition_headers_passes_through() {
661 let app = init_service(App::new().wrap(ETag::strong()).route(
662 "/",
663 web::get().to(|| async { HttpResponse::Ok().body("hello") }),
664 ))
665 .await;
666
667 let response: ServiceResponse =
668 call_service(&app, TestRequest::get().uri("/").to_request()).await;
669
670 assert_eq!(response.status(), StatusCode::OK);
671 assert!(response.headers().contains_key(header::ETAG));
672 }
673}