1use std::collections::HashMap;
8use std::fmt;
9
10use crate::bytes::Bytes;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct StatusCode(u16);
17
18impl StatusCode {
19 pub const CONTINUE: Self = Self(100);
22 pub const SWITCHING_PROTOCOLS: Self = Self(101);
24
25 pub const OK: Self = Self(200);
28 pub const CREATED: Self = Self(201);
30 pub const ACCEPTED: Self = Self(202);
32 pub const NO_CONTENT: Self = Self(204);
34 pub const PARTIAL_CONTENT: Self = Self(206);
36
37 pub const MOVED_PERMANENTLY: Self = Self(301);
40 pub const FOUND: Self = Self(302);
42 pub const SEE_OTHER: Self = Self(303);
44 pub const NOT_MODIFIED: Self = Self(304);
46 pub const TEMPORARY_REDIRECT: Self = Self(307);
48 pub const PERMANENT_REDIRECT: Self = Self(308);
50
51 pub const BAD_REQUEST: Self = Self(400);
54 pub const UNAUTHORIZED: Self = Self(401);
56 pub const FORBIDDEN: Self = Self(403);
58 pub const NOT_FOUND: Self = Self(404);
60 pub const METHOD_NOT_ALLOWED: Self = Self(405);
62 pub const REQUEST_TIMEOUT: Self = Self(408);
64 pub const CONFLICT: Self = Self(409);
66 pub const PAYLOAD_TOO_LARGE: Self = Self(413);
68 pub const UNSUPPORTED_MEDIA_TYPE: Self = Self(415);
70 pub const RANGE_NOT_SATISFIABLE: Self = Self(416);
72 pub const UNPROCESSABLE_ENTITY: Self = Self(422);
74 pub const TOO_MANY_REQUESTS: Self = Self(429);
76 pub const CLIENT_CLOSED_REQUEST: Self = Self(499);
78
79 pub const INTERNAL_SERVER_ERROR: Self = Self(500);
82 pub const NOT_IMPLEMENTED: Self = Self(501);
84 pub const BAD_GATEWAY: Self = Self(502);
86 pub const SERVICE_UNAVAILABLE: Self = Self(503);
88 pub const GATEWAY_TIMEOUT: Self = Self(504);
90
91 #[must_use]
93 pub const fn from_u16(code: u16) -> Self {
94 Self(code)
95 }
96
97 #[must_use]
99 pub const fn as_u16(self) -> u16 {
100 self.0
101 }
102
103 #[must_use]
105 pub const fn is_success(self) -> bool {
106 self.0 >= 200 && self.0 < 300
107 }
108
109 #[must_use]
111 pub const fn is_client_error(self) -> bool {
112 self.0 >= 400 && self.0 < 500
113 }
114
115 #[must_use]
117 pub const fn is_server_error(self) -> bool {
118 self.0 >= 500 && self.0 < 600
119 }
120}
121
122impl fmt::Display for StatusCode {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 write!(f, "{}", self.0)
125 }
126}
127
128#[derive(Debug, Clone)]
132pub struct Response {
133 pub status: StatusCode,
135 pub headers: HashMap<String, String>,
137 pub set_cookies: Vec<String>,
152 pub body: Bytes,
154}
155
156impl Response {
157 #[must_use]
159 pub fn new(status: StatusCode, body: impl Into<Bytes>) -> Self {
160 Self {
161 status,
162 headers: HashMap::with_capacity(4),
163 set_cookies: Vec::new(),
164 body: body.into(),
165 }
166 }
167
168 #[must_use]
170 pub fn empty(status: StatusCode) -> Self {
171 Self::new(status, Bytes::new())
172 }
173
174 #[must_use]
180 pub fn header_value(&self, name: &str) -> Option<&str> {
181 if name.eq_ignore_ascii_case("set-cookie") {
182 return self.set_cookies.first().map(String::as_str);
183 }
184 if let Some(value) = self.headers.get(name) {
185 return Some(value.as_str());
186 }
187
188 self.headers
189 .iter()
190 .filter(|(key, _)| key.eq_ignore_ascii_case(name))
191 .min_by(|(a, _), (b, _)| a.cmp(b))
192 .map(|(_, value)| value.as_str())
193 }
194
195 #[must_use]
197 pub fn has_header(&self, name: &str) -> bool {
198 if name.eq_ignore_ascii_case("set-cookie") {
199 return !self.set_cookies.is_empty();
200 }
201 self.header_value(name).is_some()
202 }
203
204 pub fn append_set_cookie(&mut self, value: impl Into<String>) {
214 self.set_cookies.push(sanitize_header_value(value.into()));
215 }
216
217 pub fn set_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
230 let normalized = sanitize_header_name(name.into()).to_ascii_lowercase();
231 if normalized == "set-cookie" {
232 self.append_set_cookie(value.into());
233 return;
234 }
235 let sanitized_value = sanitize_header_value(value.into());
236
237 self.headers
241 .retain(|key, _| !key.eq_ignore_ascii_case(&normalized));
242
243 self.headers.insert(normalized, sanitized_value);
244 }
245
246 pub fn ensure_header(&mut self, name: &str, default_value: impl Into<String>) {
257 if name.eq_ignore_ascii_case("set-cookie") {
258 if self.set_cookies.is_empty() {
259 self.append_set_cookie(default_value.into());
260 }
261 return;
262 }
263 let normalized = sanitize_header_name(name.to_owned()).to_ascii_lowercase();
264
265 let value = self
269 .headers
270 .iter()
271 .find(|(key, _)| key.eq_ignore_ascii_case(&normalized))
272 .map_or_else(|| default_value.into(), |(_, value)| value.clone());
273
274 self.headers
276 .retain(|key, _| !key.eq_ignore_ascii_case(&normalized));
277 self.headers
278 .insert(normalized, sanitize_header_value(value));
279 }
280
281 pub fn remove_header(&mut self, name: &str) -> Option<String> {
287 if name.eq_ignore_ascii_case("set-cookie") {
288 if self.set_cookies.is_empty() {
289 return None;
290 }
291 let first = self.set_cookies.remove(0);
292 self.set_cookies.clear();
293 return Some(first);
294 }
295 let normalized = name.to_ascii_lowercase();
296 let mut matching_keys: Vec<String> = self
297 .headers
298 .keys()
299 .filter(|key| key.eq_ignore_ascii_case(name))
300 .cloned()
301 .collect();
302 matching_keys.sort_by(|left, right| {
303 (left != &normalized, left.as_str()).cmp(&(right != &normalized, right.as_str()))
304 });
305 let mut removed = None;
306
307 for key in matching_keys {
308 if let Some(value) = self.headers.remove(&key) {
309 removed.get_or_insert(value);
310 }
311 }
312
313 removed
314 }
315
316 #[must_use]
318 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
319 self.set_header(name, value);
320 self
321 }
322}
323
324pub trait IntoResponse {
331 fn into_response(self) -> Response;
333}
334
335impl IntoResponse for Response {
336 fn into_response(self) -> Response {
337 self
338 }
339}
340
341impl IntoResponse for StatusCode {
342 fn into_response(self) -> Response {
343 Response::empty(self)
344 }
345}
346
347impl IntoResponse for String {
348 fn into_response(self) -> Response {
349 Response::new(StatusCode::OK, Bytes::from(self))
350 .header("content-type", "text/plain; charset=utf-8")
351 }
352}
353
354impl IntoResponse for &'static str {
355 fn into_response(self) -> Response {
356 Response::new(StatusCode::OK, Bytes::from_static(self.as_bytes()))
357 .header("content-type", "text/plain; charset=utf-8")
358 }
359}
360
361impl IntoResponse for Bytes {
362 fn into_response(self) -> Response {
363 Response::new(StatusCode::OK, self).header("content-type", "application/octet-stream")
364 }
365}
366
367impl IntoResponse for Vec<u8> {
368 fn into_response(self) -> Response {
369 Response::new(StatusCode::OK, Bytes::from(self))
370 .header("content-type", "application/octet-stream")
371 }
372}
373
374impl IntoResponse for () {
375 fn into_response(self) -> Response {
376 Response::empty(StatusCode::OK)
377 }
378}
379
380impl<T: IntoResponse> IntoResponse for (StatusCode, T) {
382 fn into_response(self) -> Response {
383 let mut resp = self.1.into_response();
384 resp.status = self.0;
385 resp
386 }
387}
388
389impl<T: IntoResponse> IntoResponse for (StatusCode, Vec<(String, String)>, T) {
391 fn into_response(self) -> Response {
392 let mut resp = self.2.into_response();
393 resp.status = self.0;
394 for (k, v) in self.1 {
395 resp.set_header(k, v);
396 }
397 resp
398 }
399}
400
401impl<T: IntoResponse, E: IntoResponse> IntoResponse for Result<T, E> {
403 fn into_response(self) -> Response {
404 match self {
405 Ok(ok) => ok.into_response(),
406 Err(err) => err.into_response(),
407 }
408 }
409}
410
411#[derive(Debug, Clone)]
423pub struct Json<T>(pub T);
424
425impl<T: serde::Serialize> IntoResponse for Json<T> {
426 fn into_response(self) -> Response {
427 serde_json::to_vec(&self.0).map_or_else(
428 |_| Response::empty(StatusCode::INTERNAL_SERVER_ERROR),
429 |body| {
430 Response::new(StatusCode::OK, Bytes::from(body))
431 .header("content-type", "application/json")
432 },
433 )
434 }
435}
436
437#[derive(Debug, Clone)]
443pub struct Html<T>(pub T);
444
445impl IntoResponse for Html<String> {
446 fn into_response(self) -> Response {
447 Response::new(StatusCode::OK, Bytes::copy_from_slice(self.0.as_bytes()))
448 .header("content-type", "text/html; charset=utf-8")
449 }
450}
451
452impl IntoResponse for Html<&'static str> {
453 fn into_response(self) -> Response {
454 Response::new(StatusCode::OK, Bytes::from_static(self.0.as_bytes()))
455 .header("content-type", "text/html; charset=utf-8")
456 }
457}
458
459#[derive(Debug, Clone, PartialEq, Eq)]
471pub enum RedirectError {
472 EmptyUri,
474 ProtocolRelative,
478 BackslashInPath,
483 SchemeNotAllowed {
488 scheme: String,
490 },
491 HostNotAllowed {
494 host: String,
496 },
497}
498
499impl fmt::Display for RedirectError {
500 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
501 match self {
502 Self::EmptyUri => write!(f, "redirect URI is empty"),
503 Self::ProtocolRelative => write!(
504 f,
505 "redirect URI starts with '//' (protocol-relative — defeats naive same-origin checks)"
506 ),
507 Self::BackslashInPath => write!(
508 f,
509 "redirect URI contains a backslash (intermediaries may normalize to '/' creating a protocol-relative URL)"
510 ),
511 Self::SchemeNotAllowed { scheme } => write!(
512 f,
513 "redirect URI scheme '{scheme}' not allowed (only 'http' and 'https')"
514 ),
515 Self::HostNotAllowed { host } => write!(
516 f,
517 "redirect URI host '{host}' not in the allowed-hosts allowlist"
518 ),
519 }
520 }
521}
522
523impl std::error::Error for RedirectError {}
524
525fn validate_redirect_uri(uri: &str, allowed_hosts: Option<&[&str]>) -> Result<(), RedirectError> {
540 if uri.is_empty() {
541 return Err(RedirectError::EmptyUri);
542 }
543 if uri.bytes().any(|b| !(0x21..=0x7E).contains(&b)) {
552 return Err(RedirectError::ProtocolRelative);
553 }
554 if uri.contains('\\') {
555 return Err(RedirectError::BackslashInPath);
556 }
557 if uri.starts_with("//") {
558 return Err(RedirectError::ProtocolRelative);
559 }
560 if let Some(rest) = uri.strip_prefix('/') {
568 let lower_first = rest.bytes().next().map(|b| b.to_ascii_lowercase());
569 if rest.starts_with("%2f")
570 || rest.starts_with("%2F")
571 || rest.starts_with("%5c")
572 || rest.starts_with("%5C")
573 || lower_first == Some(b'\\')
574 {
575 return Err(RedirectError::ProtocolRelative);
576 }
577 }
578 if uri.starts_with('/') {
579 return Ok(());
581 }
582 let (scheme, rest) = match uri.split_once(':') {
584 Some((scheme, rest)) => (scheme.to_ascii_lowercase(), rest),
585 None => {
586 return Err(RedirectError::SchemeNotAllowed {
588 scheme: String::new(),
589 });
590 }
591 };
592 if scheme != "http" && scheme != "https" {
593 return Err(RedirectError::SchemeNotAllowed { scheme });
594 }
595 let after_slashes = rest.strip_prefix("//").ok_or_else(|| {
597 RedirectError::SchemeNotAllowed {
599 scheme: scheme.clone(),
600 }
601 })?;
602 let host_with_port = after_slashes.split(['/', '?', '#']).next().unwrap_or("");
603 let host = host_with_port
604 .rsplit_once(':')
605 .map_or(host_with_port, |(h, _)| h);
606 let host = host.trim_start_matches('[').trim_end_matches(']'); if host.is_empty() {
608 return Err(RedirectError::HostNotAllowed {
609 host: String::new(),
610 });
611 }
612 let allowed_hosts = allowed_hosts.unwrap_or(&[]);
613 if allowed_hosts
614 .iter()
615 .any(|allowed| allowed.eq_ignore_ascii_case(host))
616 {
617 Ok(())
618 } else {
619 Err(RedirectError::HostNotAllowed {
620 host: host.to_string(),
621 })
622 }
623}
624
625#[derive(Debug, Clone)]
627pub struct Redirect {
628 status: StatusCode,
629 location: String,
630}
631
632impl Redirect {
633 pub fn to(uri: impl Into<String>) -> Result<Self, RedirectError> {
649 let uri = uri.into();
650 validate_redirect_uri(&uri, None)?;
651 Ok(Self {
652 status: StatusCode::FOUND,
653 location: uri,
654 })
655 }
656
657 pub fn permanent(uri: impl Into<String>) -> Result<Self, RedirectError> {
660 let uri = uri.into();
661 validate_redirect_uri(&uri, None)?;
662 Ok(Self {
663 status: StatusCode::MOVED_PERMANENTLY,
664 location: uri,
665 })
666 }
667
668 pub fn temporary(uri: impl Into<String>) -> Result<Self, RedirectError> {
671 let uri = uri.into();
672 validate_redirect_uri(&uri, None)?;
673 Ok(Self {
674 status: StatusCode::TEMPORARY_REDIRECT,
675 location: uri,
676 })
677 }
678
679 pub fn to_with_allowed_hosts(
687 uri: impl Into<String>,
688 allowed_hosts: &[&str],
689 ) -> Result<Self, RedirectError> {
690 let uri = uri.into();
691 validate_redirect_uri(&uri, Some(allowed_hosts))?;
692 Ok(Self {
693 status: StatusCode::FOUND,
694 location: uri,
695 })
696 }
697
698 #[must_use]
714 pub fn external_unchecked(uri: impl Into<String>) -> Self {
715 Self {
716 status: StatusCode::FOUND,
717 location: uri.into(),
718 }
719 }
720
721 #[must_use]
724 pub fn external_unchecked_permanent(uri: impl Into<String>) -> Self {
725 Self {
726 status: StatusCode::MOVED_PERMANENTLY,
727 location: uri.into(),
728 }
729 }
730
731 #[must_use]
734 pub fn external_unchecked_temporary(uri: impl Into<String>) -> Self {
735 Self {
736 status: StatusCode::TEMPORARY_REDIRECT,
737 location: uri.into(),
738 }
739 }
740}
741
742impl IntoResponse for Redirect {
743 fn into_response(self) -> Response {
744 let location = self
750 .location
751 .bytes()
752 .filter(|&b| (0x21..=0x7E).contains(&b))
753 .map(|b| b as char)
754 .collect::<String>();
755 Response::empty(self.status).header("location", location)
756 }
757}
758
759fn sanitize_header_value(value: String) -> String {
788 if value.bytes().all(is_valid_header_value_byte) {
789 return value;
790 }
791 let bytes: Vec<u8> = value
800 .bytes()
801 .filter(|&b| is_valid_header_value_byte(b))
802 .collect();
803 String::from_utf8(bytes)
804 .expect("filter only drops ASCII control bytes that are not UTF-8 leads/conts")
805}
806
807#[inline]
810const fn is_valid_header_value_byte(b: u8) -> bool {
811 b == 0x09 || (b >= 0x20 && b <= 0x7E) || b >= 0x80
812}
813
814fn sanitize_header_name(name: String) -> String {
823 name.bytes()
827 .filter(|&b| {
828 matches!(b,
830 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' |
831 b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' |
832 b'*' | b'+' | b'-' | b'.' | b'^' | b'_' |
833 b'`' | b'|' | b'~'
834 )
835 })
836 .map(|b| b as char)
837 .collect()
838}
839
840#[cfg(test)]
843mod tests {
844 #![allow(
845 clippy::pedantic,
846 clippy::nursery,
847 clippy::expect_fun_call,
848 clippy::map_unwrap_or,
849 clippy::cast_possible_wrap,
850 clippy::future_not_send
851 )]
852 use super::*;
853
854 #[test]
855 fn status_code_into_response() {
856 let resp = StatusCode::NOT_FOUND.into_response();
857 assert_eq!(resp.status, StatusCode::NOT_FOUND);
858 assert!(resp.body.is_empty());
859 }
860
861 #[test]
862 fn string_into_response() {
863 let resp = "hello".into_response();
864 assert_eq!(resp.status, StatusCode::OK);
865 assert_eq!(
866 resp.headers.get("content-type").unwrap(),
867 "text/plain; charset=utf-8"
868 );
869 }
870
871 #[test]
872 fn json_into_response() {
873 let resp = Json(serde_json::json!({"ok": true})).into_response();
874 assert_eq!(resp.status, StatusCode::OK);
875 assert_eq!(
876 resp.headers.get("content-type").unwrap(),
877 "application/json"
878 );
879 assert!(!resp.body.is_empty());
880 }
881
882 #[test]
883 fn html_into_response() {
884 let resp = Html("<h1>Hello</h1>").into_response();
885 assert_eq!(resp.status, StatusCode::OK);
886 assert_eq!(
887 resp.headers.get("content-type").unwrap(),
888 "text/html; charset=utf-8"
889 );
890 }
891
892 #[test]
893 fn redirect_into_response() {
894 let resp = Redirect::to("/login")
895 .expect("relative path must validate")
896 .into_response();
897 assert_eq!(resp.status, StatusCode::FOUND);
898 assert_eq!(resp.headers.get("location").unwrap(), "/login");
899 }
900
901 #[test]
906 fn redirect_to_rejects_external_uri_by_default() {
907 let err = Redirect::to("https://attacker.com/phish").unwrap_err();
909 assert!(
910 matches!(err, RedirectError::HostNotAllowed { .. }),
911 "external https URL must be rejected, got {err:?}"
912 );
913
914 let err = Redirect::to("http://attacker.com").unwrap_err();
916 assert!(matches!(err, RedirectError::HostNotAllowed { .. }));
917
918 assert!(Redirect::permanent("https://attacker.com").is_err());
920 assert!(Redirect::temporary("https://attacker.com").is_err());
921 }
922
923 #[test]
928 fn redirect_to_rejects_protocol_relative_url() {
929 let err = Redirect::to("//attacker.com/phish").unwrap_err();
930 assert!(
931 matches!(err, RedirectError::ProtocolRelative),
932 "//... URL must be rejected as ProtocolRelative, got {err:?}"
933 );
934 }
935
936 #[test]
940 fn redirect_to_rejects_backslash_path() {
941 let err = Redirect::to("/\\attacker.com/phish").unwrap_err();
942 assert!(
943 matches!(err, RedirectError::BackslashInPath),
944 "backslash in path must be rejected, got {err:?}"
945 );
946 }
947
948 #[test]
952 fn redirect_to_rejects_non_http_schemes() {
953 for uri in &[
954 "javascript:alert(1)",
955 "data:text/html,<script>alert(1)</script>",
956 "file:///etc/passwd",
957 "ftp://attacker.com/",
958 ] {
959 let err = Redirect::to(*uri).unwrap_err();
960 assert!(
961 matches!(err, RedirectError::SchemeNotAllowed { .. }),
962 "{uri} must be rejected as SchemeNotAllowed, got {err:?}"
963 );
964 }
965 }
966
967 #[test]
969 fn redirect_to_rejects_empty_uri() {
970 let err = Redirect::to("").unwrap_err();
971 assert!(matches!(err, RedirectError::EmptyUri));
972 }
973
974 #[test]
977 fn redirect_to_accepts_well_formed_relative_paths() {
978 for uri in &[
979 "/",
980 "/login",
981 "/path/with/multiple/segments",
982 "/path?with=query",
983 "/path#fragment",
984 "/path?next=/another",
985 ] {
986 assert!(
987 Redirect::to(*uri).is_ok(),
988 "relative path {uri} must validate"
989 );
990 }
991 }
992
993 #[test]
996 fn redirect_to_with_allowed_hosts_accepts_listed_rejects_others() {
997 let allowed = &["example.com", "auth.example.com"];
998
999 assert!(Redirect::to_with_allowed_hosts("https://example.com/path", allowed).is_ok());
1001 assert!(
1002 Redirect::to_with_allowed_hosts(
1003 "https://auth.example.com/oauth/callback?code=xyz",
1004 allowed
1005 )
1006 .is_ok()
1007 );
1008 assert!(Redirect::to_with_allowed_hosts("HTTPS://EXAMPLE.COM/", allowed).is_ok());
1010 assert!(Redirect::to_with_allowed_hosts("/local-path", allowed).is_ok());
1012
1013 let err =
1015 Redirect::to_with_allowed_hosts("https://attacker.com/phish", allowed).unwrap_err();
1016 assert!(matches!(err, RedirectError::HostNotAllowed { .. }));
1017
1018 let err =
1020 Redirect::to_with_allowed_hosts("https://evil.example.com/", allowed).unwrap_err();
1021 assert!(matches!(err, RedirectError::HostNotAllowed { .. }));
1022
1023 let err = Redirect::to_with_allowed_hosts("//example.com/path", allowed).unwrap_err();
1025 assert!(matches!(err, RedirectError::ProtocolRelative));
1026 }
1027
1028 #[test]
1033 fn redirect_external_unchecked_accepts_arbitrary_uri() {
1034 let r = Redirect::external_unchecked("https://anywhere.example/path?q=1");
1036 assert_eq!(r.status, StatusCode::FOUND);
1037 assert_eq!(r.location, "https://anywhere.example/path?q=1");
1038
1039 let r = Redirect::external_unchecked_permanent("https://moved.example/");
1040 assert_eq!(r.status, StatusCode::MOVED_PERMANENTLY);
1041
1042 let r = Redirect::external_unchecked_temporary("https://temp.example/");
1043 assert_eq!(r.status, StatusCode::TEMPORARY_REDIRECT);
1044 }
1045
1046 #[test]
1047 fn tuple_status_override() {
1048 let resp = (StatusCode::CREATED, "done").into_response();
1049 assert_eq!(resp.status, StatusCode::CREATED);
1050 }
1051
1052 #[test]
1053 fn response_header_helpers_are_case_insensitive() {
1054 let mut resp = Response::empty(StatusCode::OK);
1055 resp.headers
1056 .insert("Content-Type".to_string(), "text/plain".to_string());
1057
1058 assert_eq!(resp.header_value("content-type"), Some("text/plain"));
1059 assert_eq!(resp.header_value("CONTENT-TYPE"), Some("text/plain"));
1060 assert!(resp.has_header("content-type"));
1061 }
1062
1063 #[test]
1064 fn response_set_header_canonicalizes_existing_case_variant() {
1065 let mut resp = Response::empty(StatusCode::OK);
1066 resp.headers
1067 .insert("X-Trace-Id".to_string(), "old".to_string());
1068
1069 resp.set_header("x-trace-id", "new");
1070
1071 assert_eq!(resp.headers.get("x-trace-id"), Some(&"new".to_string()));
1072 assert!(!resp.headers.contains_key("X-Trace-Id"));
1073 }
1074
1075 #[test]
1076 fn response_ensure_header_preserves_existing_value_and_canonicalizes_name() {
1077 let mut resp = Response::empty(StatusCode::OK);
1078 resp.headers
1079 .insert("Server".to_string(), "custom".to_string());
1080
1081 resp.ensure_header("server", "fallback");
1082
1083 assert_eq!(resp.headers.get("server"), Some(&"custom".to_string()));
1084 assert!(!resp.headers.contains_key("Server"));
1085 }
1086
1087 #[test]
1088 fn response_remove_header_clears_case_variants() {
1089 let mut resp = Response::empty(StatusCode::OK);
1090 resp.headers.insert("Server".to_string(), "one".to_string());
1091 resp.headers.insert("server".to_string(), "two".to_string());
1092
1093 let removed = resp.remove_header("SERVER");
1094
1095 assert_eq!(removed.as_deref(), Some("two"));
1096 assert!(!resp.has_header("server"));
1097 assert!(resp.headers.is_empty());
1098 }
1099
1100 #[test]
1101 fn result_ok_response() {
1102 let resp: Result<&str, StatusCode> = Ok("success");
1103 let r = resp.into_response();
1104 assert_eq!(r.status, StatusCode::OK);
1105 }
1106
1107 #[test]
1108 fn result_err_response() {
1109 let resp: Result<&str, StatusCode> = Err(StatusCode::BAD_REQUEST);
1110 let r = resp.into_response();
1111 assert_eq!(r.status, StatusCode::BAD_REQUEST);
1112 }
1113
1114 #[test]
1115 fn status_code_properties() {
1116 assert!(StatusCode::OK.is_success());
1117 assert!(!StatusCode::OK.is_client_error());
1118 assert!(StatusCode::NOT_FOUND.is_client_error());
1119 assert!(StatusCode::INTERNAL_SERVER_ERROR.is_server_error());
1120 }
1121
1122 #[test]
1127 fn status_code_debug_clone_copy_hash_display() {
1128 use std::collections::HashSet;
1129 let sc = StatusCode::OK;
1130 let dbg = format!("{sc:?}");
1131 assert!(dbg.contains("StatusCode"), "{dbg}");
1132 assert!(dbg.contains("200"), "{dbg}");
1133 let copied = sc;
1134 let cloned = sc;
1135 assert_eq!(copied, cloned);
1136 let display = format!("{sc}");
1137 assert_eq!(display, "200");
1138 let mut set = HashSet::new();
1139 set.insert(sc);
1140 assert!(set.contains(&StatusCode::OK));
1141 }
1142
1143 #[test]
1144 fn response_debug_clone() {
1145 let resp = Response::new(StatusCode::OK, Bytes::from_static(b"hi"));
1146 let dbg = format!("{resp:?}");
1147 assert!(dbg.contains("Response"), "{dbg}");
1148 let cloned = resp;
1149 assert_eq!(cloned.status, StatusCode::OK);
1150 }
1151
1152 #[test]
1153 fn redirect_debug_clone() {
1154 let r = Redirect::to("/home").expect("relative path must validate");
1155 let dbg = format!("{r:?}");
1156 assert!(dbg.contains("Redirect"), "{dbg}");
1157 let cloned = r;
1158 let dbg2 = format!("{cloned:?}");
1159 assert_eq!(dbg, dbg2);
1160 }
1161
1162 #[test]
1167 fn set_header_strips_crlf_from_value() {
1168 let mut resp = Response::empty(StatusCode::OK);
1169 resp.set_header("x-test", "value\r\nEvil-Header: injected");
1170 assert_eq!(
1171 resp.headers.get("x-test").unwrap(),
1172 "valueEvil-Header: injected"
1173 );
1174 }
1175
1176 #[test]
1177 fn set_header_strips_bare_lf_from_value() {
1178 let mut resp = Response::empty(StatusCode::OK);
1179 resp.set_header("x-test", "line1\nline2");
1180 assert_eq!(resp.headers.get("x-test").unwrap(), "line1line2");
1181 }
1182
1183 #[test]
1184 fn set_header_strips_bare_cr_from_value() {
1185 let mut resp = Response::empty(StatusCode::OK);
1186 resp.set_header("x-test", "line1\rline2");
1187 assert_eq!(resp.headers.get("x-test").unwrap(), "line1line2");
1188 }
1189
1190 #[test]
1191 fn builder_header_strips_crlf() {
1192 let resp = Response::empty(StatusCode::OK).header("x-test", "safe\r\nX-Injected: oops");
1193 assert_eq!(resp.headers.get("x-test").unwrap(), "safeX-Injected: oops");
1194 }
1195
1196 #[test]
1197 fn ensure_header_strips_crlf_from_default() {
1198 let mut resp = Response::empty(StatusCode::OK);
1199 resp.ensure_header("x-test", "default\r\nEvil: yes");
1200 assert_eq!(resp.headers.get("x-test").unwrap(), "defaultEvil: yes");
1201 }
1202
1203 #[test]
1204 fn tuple_headers_strip_crlf() {
1205 let resp = (
1206 StatusCode::OK,
1207 vec![("x-test".to_string(), "a\r\nb".to_string())],
1208 "body",
1209 )
1210 .into_response();
1211 assert_eq!(resp.headers.get("x-test").unwrap(), "ab");
1212 }
1213
1214 #[test]
1215 fn set_header_strips_crlf_from_name() {
1216 let mut resp = Response::empty(StatusCode::OK);
1217 resp.set_header("x-test\r\nEvil-Header: injected", "value");
1218 assert!(resp.headers.contains_key("x-testevil-headerinjected"));
1222 assert!(
1223 !resp
1224 .headers
1225 .keys()
1226 .any(|k| k.contains(['\r', '\n', ':', ' ']))
1227 );
1228 }
1229
1230 #[test]
1231 fn ensure_header_strips_crlf_from_name() {
1232 let mut resp = Response::empty(StatusCode::OK);
1233 resp.ensure_header("x-test\r\nEvil:", "value");
1234 assert!(
1235 !resp
1236 .headers
1237 .keys()
1238 .any(|k| k.contains('\r') || k.contains('\n'))
1239 );
1240 }
1241
1242 #[test]
1243 fn tuple_headers_strip_crlf_from_name() {
1244 let resp = (
1245 StatusCode::OK,
1246 vec![("x-test\r\nEvil:".to_string(), "value".to_string())],
1247 "body",
1248 )
1249 .into_response();
1250 assert!(
1251 !resp
1252 .headers
1253 .keys()
1254 .any(|k| k.contains('\r') || k.contains('\n'))
1255 );
1256 }
1257
1258 #[test]
1259 fn clean_header_value_passes_through_unchanged() {
1260 let mut resp = Response::empty(StatusCode::OK);
1261 resp.set_header("x-test", "normal-value");
1262 assert_eq!(resp.headers.get("x-test").unwrap(), "normal-value");
1263 }
1264
1265 #[test]
1272 fn set_cookie_appends_instead_of_overwriting() {
1273 let mut resp = Response::empty(StatusCode::OK);
1274 resp.set_header("set-cookie", "csrf=abc123; HttpOnly");
1275 resp.set_header("set-cookie", "session=def456; HttpOnly; Secure");
1276 assert_eq!(resp.set_cookies.len(), 2, "both cookies must survive");
1277 assert_eq!(resp.set_cookies[0], "csrf=abc123; HttpOnly");
1278 assert_eq!(resp.set_cookies[1], "session=def456; HttpOnly; Secure");
1279 assert!(!resp.headers.contains_key("set-cookie"));
1281 assert_eq!(
1284 resp.header_value("Set-Cookie"),
1285 Some("csrf=abc123; HttpOnly"),
1286 );
1287 assert!(resp.has_header("set-cookie"));
1288 }
1289
1290 #[test]
1294 fn append_set_cookie_strips_crlf_from_value() {
1295 let mut resp = Response::empty(StatusCode::OK);
1296 resp.append_set_cookie("session=abc\r\nX-Injected: yes");
1297 assert_eq!(resp.set_cookies.len(), 1);
1298 assert!(!resp.set_cookies[0].contains('\r'));
1299 assert!(!resp.set_cookies[0].contains('\n'));
1300 }
1301
1302 #[test]
1306 fn remove_set_cookie_drains_all_queued_cookies() {
1307 let mut resp = Response::empty(StatusCode::OK);
1308 resp.append_set_cookie("a=1");
1309 resp.append_set_cookie("b=2");
1310 let dropped = resp.remove_header("Set-Cookie");
1311 assert_eq!(dropped.as_deref(), Some("a=1"));
1312 assert!(resp.set_cookies.is_empty(), "no cookies should remain");
1313 }
1314
1315 #[test]
1316 fn json_html_debug_clone() {
1317 let j = Json(42);
1318 let dbg = format!("{j:?}");
1319 assert!(dbg.contains("Json"), "{dbg}");
1320 let jc = j;
1321 assert_eq!(format!("{jc:?}"), dbg);
1322
1323 let h = Html("hello");
1324 let dbg2 = format!("{h:?}");
1325 assert!(dbg2.contains("Html"), "{dbg2}");
1326 let hc = h.clone();
1327 assert_eq!(format!("{hc:?}"), dbg2);
1328 }
1329
1330 #[test]
1335 fn _5jtjo0_strips_nul_byte_from_header_value() {
1336 let raw = String::from("alice\u{0000}forged-header: value");
1337 let cleaned = sanitize_header_value(raw);
1338 assert!(!cleaned.contains('\u{0000}'));
1339 assert_eq!(cleaned, "aliceforged-header: value");
1340 }
1341
1342 #[test]
1343 fn _5jtjo0_strips_c0_control_bytes() {
1344 let raw: String = (0x01u8..=0x1F)
1346 .filter(|b| *b != 0x09) .map(|b| b as char)
1348 .collect::<String>()
1349 + "trailing";
1350 let cleaned = sanitize_header_value(raw);
1351 assert_eq!(cleaned, "trailing");
1353 }
1354
1355 #[test]
1356 fn _5jtjo0_preserves_htab_space_printable_ascii() {
1357 let raw = String::from("\tHello, World! 123 -_+=()[];,./?\\:");
1358 let cleaned = sanitize_header_value(raw.clone());
1359 assert_eq!(cleaned, raw);
1360 }
1361
1362 #[test]
1363 fn _5jtjo0_preserves_obs_text_utf8_passthrough() {
1364 let raw = String::from("café résumé日本語");
1367 let cleaned = sanitize_header_value(raw.clone());
1368 assert_eq!(cleaned, raw);
1369 }
1370
1371 #[test]
1372 fn _5jtjo0_strips_crlf_legacy_behavior_preserved() {
1373 let raw = String::from("first\r\nforged-header: bad");
1374 let cleaned = sanitize_header_value(raw);
1375 assert_eq!(cleaned, "firstforged-header: bad");
1376 }
1377
1378 #[test]
1379 fn _5jtjo0_strips_del_byte() {
1380 let raw = String::from("hello\u{007F}world");
1381 let cleaned = sanitize_header_value(raw);
1382 assert_eq!(cleaned, "helloworld");
1383 }
1384
1385 #[test]
1394 fn n5b94b_redirect_sanitization_matches_validation_strictness() {
1395 let redirect = Redirect::external_unchecked("http://example.com/path\x01\x1F");
1397 let response = redirect.into_response();
1398 let location = response.headers.get("location").unwrap();
1399
1400 assert!(!location.contains('\x01'));
1402 assert!(!location.contains('\x1F'));
1403 assert_eq!(location, "http://example.com/path");
1404 }
1405
1406 #[test]
1407 fn n5b94b_header_name_sanitization_consistency() {
1408 let mut resp = Response::new(StatusCode::OK, "test");
1409
1410 resp.set_header("x-test\r\n-header\x01", "value");
1412
1413 let headers: Vec<_> = resp.headers.keys().collect();
1415 assert_eq!(headers.len(), 1);
1416 assert_eq!(headers[0], "x-test-header");
1417 }
1418
1419 #[test]
1420 fn n5b94b_header_case_normalization_atomic() {
1421 let mut resp = Response::new(StatusCode::OK, "test");
1422
1423 resp.headers
1425 .insert("X-Test".to_string(), "value1".to_string());
1426 resp.headers
1427 .insert("x-TEST".to_string(), "value2".to_string());
1428 resp.headers
1429 .insert("X-test".to_string(), "value3".to_string());
1430
1431 resp.set_header("x-test", "final");
1433
1434 let test_headers: Vec<_> = resp
1435 .headers
1436 .iter()
1437 .filter(|(k, _)| k.eq_ignore_ascii_case("x-test"))
1438 .collect();
1439
1440 assert_eq!(
1441 test_headers.len(),
1442 1,
1443 "All case variants should be removed atomically"
1444 );
1445 assert_eq!(test_headers[0].0, "x-test");
1446 assert_eq!(test_headers[0].1, "final");
1447 }
1448
1449 #[test]
1450 fn n5b94b_ensure_header_atomic_check_and_set() {
1451 let mut resp = Response::new(StatusCode::OK, "test");
1452
1453 resp.headers
1455 .insert("X-Custom".to_string(), "existing".to_string());
1456
1457 resp.ensure_header("x-custom", "default");
1459
1460 let custom_headers: Vec<_> = resp
1461 .headers
1462 .iter()
1463 .filter(|(k, _)| k.eq_ignore_ascii_case("x-custom"))
1464 .collect();
1465
1466 assert_eq!(
1467 custom_headers.len(),
1468 1,
1469 "Should be exactly one header after ensure"
1470 );
1471 assert_eq!(custom_headers[0].0, "x-custom"); assert_eq!(custom_headers[0].1, "existing"); }
1474
1475 #[test]
1476 fn oms1b7_rejects_protocol_relative() {
1477 let err = Redirect::to("//attacker.com/path").unwrap_err();
1478 assert!(matches!(err, RedirectError::ProtocolRelative));
1479 }
1480
1481 #[test]
1482 fn oms1b7_rejects_leading_whitespace_then_protocol_relative() {
1483 let err = Redirect::to(" //attacker.com").unwrap_err();
1486 assert!(matches!(err, RedirectError::ProtocolRelative), "{err:?}");
1487 }
1488
1489 #[test]
1490 fn oms1b7_rejects_leading_tab_then_protocol_relative() {
1491 let err = Redirect::to("\t//attacker.com").unwrap_err();
1492 assert!(matches!(err, RedirectError::ProtocolRelative), "{err:?}");
1493 }
1494
1495 #[test]
1496 fn oms1b7_rejects_leading_crlf() {
1497 let err = Redirect::to("\r\n//attacker.com").unwrap_err();
1498 assert!(matches!(err, RedirectError::ProtocolRelative), "{err:?}");
1499 }
1500
1501 #[test]
1502 fn oms1b7_rejects_percent_encoded_double_slash() {
1503 let err = Redirect::to("/%2fattacker.com").unwrap_err();
1506 assert!(matches!(err, RedirectError::ProtocolRelative), "{err:?}");
1507 let err = Redirect::to("/%2Fattacker.com").unwrap_err();
1508 assert!(matches!(err, RedirectError::ProtocolRelative), "{err:?}");
1509 }
1510
1511 #[test]
1512 fn oms1b7_rejects_percent_encoded_backslash_after_slash() {
1513 let err = Redirect::to("/%5cattacker.com").unwrap_err();
1514 assert!(matches!(err, RedirectError::ProtocolRelative), "{err:?}");
1515 }
1516
1517 #[test]
1518 fn oms1b7_accepts_legitimate_relative_paths() {
1519 assert!(Redirect::to("/login").is_ok());
1520 assert!(Redirect::to("/api/v1/foo?x=1&y=2").is_ok());
1521 assert!(Redirect::to("/path#anchor").is_ok());
1522 }
1523
1524 #[test]
1525 fn oms1b7_rejects_null_byte_in_uri() {
1526 let err = Redirect::to("/safe\u{0000}//attacker.com").unwrap_err();
1527 assert!(matches!(err, RedirectError::ProtocolRelative), "{err:?}");
1529 }
1530}