Skip to main content

asupersync/web/
response.rs

1//! Response types and the [`IntoResponse`] trait.
2//!
3//! Handlers return types that implement [`IntoResponse`], which converts them
4//! into an HTTP response. Common types like `String`, `&str`, `Json<T>`, and
5//! tuples are supported out of the box.
6
7use std::collections::HashMap;
8use std::fmt;
9
10use crate::bytes::Bytes;
11
12// ─── Status Codes ────────────────────────────────────────────────────────────
13
14/// HTTP status code.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub struct StatusCode(u16);
17
18impl StatusCode {
19    // 1xx Informational
20    /// 100 Continue
21    pub const CONTINUE: Self = Self(100);
22    /// 101 Switching Protocols
23    pub const SWITCHING_PROTOCOLS: Self = Self(101);
24
25    // 2xx Success
26    /// 200 OK
27    pub const OK: Self = Self(200);
28    /// 201 Created
29    pub const CREATED: Self = Self(201);
30    /// 202 Accepted
31    pub const ACCEPTED: Self = Self(202);
32    /// 204 No Content
33    pub const NO_CONTENT: Self = Self(204);
34    /// 206 Partial Content
35    pub const PARTIAL_CONTENT: Self = Self(206);
36
37    // 3xx Redirection
38    /// 301 Moved Permanently
39    pub const MOVED_PERMANENTLY: Self = Self(301);
40    /// 302 Found
41    pub const FOUND: Self = Self(302);
42    /// 303 See Other
43    pub const SEE_OTHER: Self = Self(303);
44    /// 304 Not Modified
45    pub const NOT_MODIFIED: Self = Self(304);
46    /// 307 Temporary Redirect
47    pub const TEMPORARY_REDIRECT: Self = Self(307);
48    /// 308 Permanent Redirect
49    pub const PERMANENT_REDIRECT: Self = Self(308);
50
51    // 4xx Client Error
52    /// 400 Bad Request
53    pub const BAD_REQUEST: Self = Self(400);
54    /// 401 Unauthorized
55    pub const UNAUTHORIZED: Self = Self(401);
56    /// 403 Forbidden
57    pub const FORBIDDEN: Self = Self(403);
58    /// 404 Not Found
59    pub const NOT_FOUND: Self = Self(404);
60    /// 405 Method Not Allowed
61    pub const METHOD_NOT_ALLOWED: Self = Self(405);
62    /// 408 Request Timeout
63    pub const REQUEST_TIMEOUT: Self = Self(408);
64    /// 409 Conflict
65    pub const CONFLICT: Self = Self(409);
66    /// 413 Payload Too Large
67    pub const PAYLOAD_TOO_LARGE: Self = Self(413);
68    /// 415 Unsupported Media Type
69    pub const UNSUPPORTED_MEDIA_TYPE: Self = Self(415);
70    /// 416 Range Not Satisfiable
71    pub const RANGE_NOT_SATISFIABLE: Self = Self(416);
72    /// 422 Unprocessable Entity
73    pub const UNPROCESSABLE_ENTITY: Self = Self(422);
74    /// 429 Too Many Requests
75    pub const TOO_MANY_REQUESTS: Self = Self(429);
76    /// 499 Client Closed Request
77    pub const CLIENT_CLOSED_REQUEST: Self = Self(499);
78
79    // 5xx Server Error
80    /// 500 Internal Server Error
81    pub const INTERNAL_SERVER_ERROR: Self = Self(500);
82    /// 501 Not Implemented
83    pub const NOT_IMPLEMENTED: Self = Self(501);
84    /// 502 Bad Gateway
85    pub const BAD_GATEWAY: Self = Self(502);
86    /// 503 Service Unavailable
87    pub const SERVICE_UNAVAILABLE: Self = Self(503);
88    /// 504 Gateway Timeout
89    pub const GATEWAY_TIMEOUT: Self = Self(504);
90
91    /// Create a status code from a raw value.
92    #[must_use]
93    pub const fn from_u16(code: u16) -> Self {
94        Self(code)
95    }
96
97    /// Return the numeric status code.
98    #[must_use]
99    pub const fn as_u16(self) -> u16 {
100        self.0
101    }
102
103    /// Returns `true` if the status code indicates success (2xx).
104    #[must_use]
105    pub const fn is_success(self) -> bool {
106        self.0 >= 200 && self.0 < 300
107    }
108
109    /// Returns `true` if the status code indicates a client error (4xx).
110    #[must_use]
111    pub const fn is_client_error(self) -> bool {
112        self.0 >= 400 && self.0 < 500
113    }
114
115    /// Returns `true` if the status code indicates a server error (5xx).
116    #[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// ─── Response ────────────────────────────────────────────────────────────────
129
130/// An HTTP response.
131#[derive(Debug, Clone)]
132pub struct Response {
133    /// HTTP status code.
134    pub status: StatusCode,
135    /// Response headers.
136    pub headers: HashMap<String, String>,
137    /// Set-Cookie response header lines, one per cookie.
138    ///
139    /// br-asupersync-ehtkns: `Set-Cookie` is the canonical multi-valued
140    /// response header — each cookie must ship as its own header line.
141    /// Storing it in `headers` (a single-value `HashMap`) silently
142    /// overwrote earlier cookies whenever a second one was set, e.g.
143    /// when `SessionMiddleware` set the session cookie after a handler
144    /// had already set a CSRF / remember-me cookie. All Set-Cookie
145    /// entries now live here; wire-format writers must emit one
146    /// `Set-Cookie:` line per entry. The public API funnels Set-Cookie
147    /// values into this vector via [`Response::append_set_cookie`] (and
148    /// transparently from [`Response::set_header`] when the name is
149    /// `set-cookie`), so existing call sites cannot accidentally lose
150    /// cookies.
151    pub set_cookies: Vec<String>,
152    /// Response body.
153    pub body: Bytes,
154}
155
156impl Response {
157    /// Create a new response with the given status, headers, and body.
158    #[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    /// Create an empty response with the given status code.
169    #[must_use]
170    pub fn empty(status: StatusCode) -> Self {
171        Self::new(status, Bytes::new())
172    }
173
174    /// Returns a header value using HTTP's case-insensitive matching rules.
175    ///
176    /// For `set-cookie`, returns the FIRST entry of [`Self::set_cookies`]
177    /// (callers needing every cookie should iterate `set_cookies` directly,
178    /// since `Set-Cookie` is canonically multi-valued).
179    #[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    /// Returns `true` when the response contains the named header.
196    #[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    /// Append a `Set-Cookie` response header line.
205    ///
206    /// br-asupersync-ehtkns: the explicit, append-only API for cookies.
207    /// Each call adds a separate `Set-Cookie:` line on the wire, so
208    /// composed middleware (session, CSRF, remember-me, flash) can each
209    /// ship their own cookie without clobbering the others. The value
210    /// is sanitized through `sanitize_header_value` for parity with
211    /// `set_header`, so CR/LF/NUL/control bytes can never split the
212    /// header.
213    pub fn append_set_cookie(&mut self, value: impl Into<String>) {
214        self.set_cookies.push(sanitize_header_value(value.into()));
215    }
216
217    /// Insert or replace a header while canonicalizing the stored name.
218    ///
219    /// br-asupersync-n5b94b: TOCTOU FIX - perform atomic header key normalization
220    /// to prevent race conditions where multiple headers with case-variant names
221    /// could exist simultaneously. Both names and values are sanitized using
222    /// consistent logic to prevent injection attacks.
223    ///
224    /// br-asupersync-ehtkns: when `name` is `set-cookie`, the call is
225    /// transparently routed to [`Self::append_set_cookie`] so multiple
226    /// composed middleware layers each get to emit their own cookie
227    /// instead of overwriting one another. To remove all previously
228    /// queued cookies, clear [`Self::set_cookies`] explicitly.
229    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        // Atomic removal of all case-variant keys - collect AND remove in the
238        // same iteration to prevent TOCTOU where case variants could be added
239        // between collection and removal phases
240        self.headers
241            .retain(|key, _| !key.eq_ignore_ascii_case(&normalized));
242
243        self.headers.insert(normalized, sanitized_value);
244    }
245
246    /// Ensure a header exists while preserving any existing value.
247    ///
248    /// br-asupersync-n5b94b: TOCTOU FIX - atomic header processing to prevent
249    /// race conditions. The name is sanitized using consistent validation that
250    /// matches header value sanitization.
251    ///
252    /// br-asupersync-ehtkns: when `name` is `set-cookie`, appends the
253    /// default value only when no cookies are currently queued. Use
254    /// [`Self::append_set_cookie`] to add additional cookies regardless
255    /// of state.
256    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        // Atomic check-and-set: find existing value or use default, then
266        // set atomically to prevent TOCTOU where header could change between
267        // check and set operations
268        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        // Remove all case variants atomically
275        self.headers
276            .retain(|key, _| !key.eq_ignore_ascii_case(&normalized));
277        self.headers
278            .insert(normalized, sanitize_header_value(value));
279    }
280
281    /// Remove a header using HTTP's case-insensitive matching rules.
282    ///
283    /// br-asupersync-ehtkns: when `name` is `set-cookie`, drains all
284    /// queued cookies from [`Self::set_cookies`] and returns the first
285    /// one (preserving the legacy single-value return shape).
286    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    /// Add a header to the response.
317    #[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
324// ─── IntoResponse Trait ──────────────────────────────────────────────────────
325
326/// Trait for types that can be converted into an HTTP response.
327///
328/// This is the primary mechanism for returning data from handlers.
329/// Any handler return type must implement this trait.
330pub trait IntoResponse {
331    /// Convert self into a [`Response`].
332    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
380/// Tuple: (StatusCode, body) overrides the status code.
381impl<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
389/// Tuple: (StatusCode, headers, body) overrides status and adds headers.
390impl<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
401/// Result: Ok produces the success response, Err the error response.
402impl<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// ─── Json Response ───────────────────────────────────────────────────────────
412
413/// JSON response wrapper.
414///
415/// Serializes the inner value as JSON with `application/json` content type.
416///
417/// ```ignore
418/// async fn get_user() -> Json<User> {
419///     Json(User { name: "alice".into() })
420/// }
421/// ```
422#[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// ─── Html Response ───────────────────────────────────────────────────────────
438
439/// HTML response wrapper.
440///
441/// Sets the content type to `text/html; charset=utf-8`.
442#[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// ─── Redirect ────────────────────────────────────────────────────────────────
460
461/// Why a redirect URI was rejected by the safe-by-default validators
462/// (`Redirect::to`, `Redirect::permanent`, `Redirect::temporary`).
463///
464/// br-asupersync-0hj233: this enum surfaces the open-redirect defense
465/// as an explicit error type so callers either (a) handle the error
466/// (return 400 to the user) or (b) opt into the explicit
467/// `Redirect::external_unchecked` escape hatch when they truly need
468/// to redirect to an external host (OAuth callbacks, payment-gateway
469/// hand-offs, etc.).
470#[derive(Debug, Clone, PartialEq, Eq)]
471pub enum RedirectError {
472    /// URI is empty.
473    EmptyUri,
474    /// URI starts with `//` — protocol-relative, browser switches host
475    /// to whatever follows the slashes. Trivial open-redirect vector
476    /// that defeats naive `starts_with("/")` defenses.
477    ProtocolRelative,
478    /// URI contains a backslash (`\`). Some HTTP intermediaries and
479    /// browsers normalize `\` → `/`, so `/\\attacker.com/x` becomes
480    /// `//attacker.com/x` — the protocol-relative attack via a
481    /// different parser quirk.
482    BackslashInPath,
483    /// URI has a scheme other than `http` or `https` (e.g.,
484    /// `javascript:`, `data:`, `file:`, `ftp:`). javascript: redirects
485    /// in Location headers were historically followed by some browsers
486    /// and remain a source of XSS.
487    SchemeNotAllowed {
488        /// The rejected scheme (e.g., `"javascript"`).
489        scheme: String,
490    },
491    /// URI has an absolute http(s) URL but its host is not in the
492    /// caller-provided `allowed_hosts` allowlist.
493    HostNotAllowed {
494        /// The host that was rejected.
495        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
525/// br-asupersync-0hj233: validate a candidate redirect URI for
526/// open-redirect safety. Used by [`Redirect::to`] /
527/// [`Redirect::permanent`] / [`Redirect::temporary`] (relative-only
528/// strict mode) and [`Redirect::to_with_allowed_hosts`] (allowlist
529/// mode).
530///
531/// **Strict mode (`allowed_hosts` is `None` or empty):**
532/// - URI MUST start with `/`
533/// - URI MUST NOT start with `//` (protocol-relative)
534/// - URI MUST NOT contain backslash (`\`)
535///
536/// **Allowlist mode (`allowed_hosts` is `Some(&[...])`):**
537/// - Same rules as strict mode for relative paths, OR
538/// - Absolute http(s) URI whose host appears in `allowed_hosts`
539fn validate_redirect_uri(uri: &str, allowed_hosts: Option<&[&str]>) -> Result<(), RedirectError> {
540    if uri.is_empty() {
541        return Err(RedirectError::EmptyUri);
542    }
543    // br-asupersync-oms1b7: reject any byte outside the
544    // authority/path-allowed printable-ASCII set. RFC 3986 §3 caps
545    // URI bytes at the unreserved + reserved + percent-encoded
546    // alphabet, all of which fall in 0x21..=0x7E. Leading whitespace,
547    // CR/LF, NUL, and control bytes are all rejected here so that
548    // the protocol-relative `//` check downstream cannot be
549    // sidestepped by `\u{0009}//attacker.com`,
550    // `\u{0020}//attacker.com`, `\r\n//attacker.com`, etc.
551    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    // br-asupersync-oms1b7: also reject single-slash forms that some
561    // browsers historically interpreted as protocol-relative when
562    // the second character was unusual (`/\\attacker.com` is already
563    // rejected by the BackslashInPath check above; this guards
564    // against the `/%2f`-style encoded variant). The strictest
565    // posture: a relative redirect must be `/` followed by a
566    // non-`/`, non-`%2f`, non-`%5C` character.
567    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        // Relative path — accepted under both strict and allowlist modes.
580        return Ok(());
581    }
582    // Not a relative path. Must be an absolute URI with a recognised scheme.
583    let (scheme, rest) = match uri.split_once(':') {
584        Some((scheme, rest)) => (scheme.to_ascii_lowercase(), rest),
585        None => {
586            // No scheme separator AND not relative — reject as malformed.
587            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    // http(s) URI: extract host from `//host[:port]/path` form.
596    let after_slashes = rest.strip_prefix("//").ok_or_else(|| {
597        // http(s) URI must have `://` — without it, treat as bad.
598        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(']'); // IPv6 brackets
607    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/// HTTP redirect response.
626#[derive(Debug, Clone)]
627pub struct Redirect {
628    status: StatusCode,
629    location: String,
630}
631
632impl Redirect {
633    /// 302 Found redirect.
634    ///
635    /// # Safe-by-default validation (br-asupersync-0hj233)
636    ///
637    /// Returns `Err(RedirectError)` for any URI that is not a
638    /// site-relative path (`/foo`). Specifically rejects:
639    /// - empty strings,
640    /// - protocol-relative URIs (`//attacker.com/...`),
641    /// - URIs containing backslash (`/\\attacker.com/...`),
642    /// - any URI with a scheme (`javascript:`, `https://attacker.com/`, ...).
643    ///
644    /// For redirects that legitimately point at an external host (OAuth
645    /// callbacks, payment hand-offs), use [`Self::to_with_allowed_hosts`]
646    /// (validated against an allowlist) or [`Self::external_unchecked`]
647    /// (caller asserts the URI is trustworthy).
648    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    /// 301 Moved Permanently redirect. Same safe-by-default validation
658    /// as [`Self::to`]; see that method for details.
659    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    /// 307 Temporary Redirect (preserves method). Same safe-by-default
669    /// validation as [`Self::to`]; see that method for details.
670    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    /// 302 Found redirect with an explicit allowed-hosts allowlist
680    /// (br-asupersync-0hj233).
681    ///
682    /// Accepts site-relative paths AND absolute http(s) URIs whose
683    /// host appears (case-insensitive) in `allowed_hosts`. Use this
684    /// for redirect flows whose target host space is
685    /// statically-known (OAuth providers, payment gateways).
686    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    /// **Unchecked** 302 Found redirect — caller asserts the URI is
699    /// trustworthy (br-asupersync-0hj233).
700    ///
701    /// This bypasses the open-redirect validation in [`Self::to`].
702    /// Use ONLY when the URI is genuinely controlled by the
703    /// application (a hard-coded constant, a value derived from
704    /// trusted server-side state, or an OAuth provider URL whose
705    /// host is independently verified). NEVER pass user-supplied
706    /// strings (URL parameters, form fields, request body) to this
707    /// constructor — that's the canonical phishing vector this bead
708    /// is defending against.
709    ///
710    /// The CRLF stripping in the wire-format step (see
711    /// `into_response`) still applies — this only bypasses the
712    /// scheme/host validation.
713    #[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    /// **Unchecked** 301 Moved Permanently redirect; see
722    /// [`Self::external_unchecked`] for the safety contract.
723    #[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    /// **Unchecked** 307 Temporary Redirect; see
732    /// [`Self::external_unchecked`] for the safety contract.
733    #[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        // br-asupersync-n5b94b: TOCTOU FIX - ensure final sanitization matches
745        // the strict validation contract from validate_redirect_uri(). The
746        // validation rejects ALL bytes outside 0x21-0x7E, so final sanitization
747        // must enforce the same constraint to prevent control character
748        // injection if validation is bypassed or weakened in future changes.
749        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
759// ─── Header Sanitization ─────────────────────────────────────────────────────
760
761/// Strip every byte that RFC 9110 §5.5 forbids inside a `field-value`
762/// from a header value (br-asupersync-5jtjo0).
763///
764/// RFC 9110 §5.5 defines `field-value = *( field-vchar [ 1*( SP / HTAB
765/// / field-vchar ) field-vchar ] )` where `field-vchar = VCHAR /
766/// obs-text` and `VCHAR = %x21-7E`. The legal byte set is therefore
767///
768///    HTAB (0x09), SP (0x20), VCHAR (0x21..=0x7E), obs-text (0x80..=0xFF)
769///
770/// EVERY OTHER byte (NUL 0x00, the C0 controls 0x01..=0x08,
771/// 0x0A=LF, 0x0B=VT, 0x0C=FF, 0x0D=CR, 0x0E..=0x1F, DEL 0x7F) is a
772/// header-value-syntax violation. The previous implementation only
773/// stripped CR and LF — leaving NUL, BS, VT, FF, ESC, etc. to flow
774/// through unfiltered. Embedded NUL is the highest-impact case: a
775/// downstream proxy / WAF / log collector that scans the wire format
776/// with C string semantics treats NUL as end-of-line and may parse a
777/// forged additional header from whatever follows. VT/FF likewise smuggle
778/// past tools that only look for CRLF.
779///
780/// Allowlist semantics: the function preserves HTAB / SP / printable
781/// ASCII / obs-text and replaces every other byte with nothing
782/// (deletion, not substitution — substitution would leak length
783/// information that could be used as a covert channel). Empty result
784/// is acceptable; it produces an empty header value, which the
785/// wire-format codec serialises as `name:` with no value (RFC 9110 §5.5
786/// allows empty field-values).
787fn sanitize_header_value(value: String) -> String {
788    if value.bytes().all(is_valid_header_value_byte) {
789        return value;
790    }
791    // Filter byte-by-byte. We only ever drop bytes <= 0x7F that fail
792    // the allowlist (NUL, the C0 controls except HTAB, DEL); UTF-8
793    // lead bytes (0xC0..=0xFD) and continuation bytes (0x80..=0xBF)
794    // are all >= 0x80 and pass through. Multi-byte UTF-8 sequences
795    // therefore stay intact byte-for-byte, so the resulting Vec<u8>
796    // is still valid UTF-8 and `from_utf8` succeeds. The infallible
797    // `expect` documents the invariant; if it ever fires, the
798    // allowlist function above changed in a way that broke UTF-8.
799    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/// br-asupersync-5jtjo0: byte-level allowlist for header-value syntax.
808/// HTAB, SP, printable ASCII, and obs-text are accepted.
809#[inline]
810const fn is_valid_header_value_byte(b: u8) -> bool {
811    b == 0x09 || (b >= 0x20 && b <= 0x7E) || b >= 0x80
812}
813
814/// Strip CR and LF from a header name to prevent CRLF injection attacks.
815///
816/// br-asupersync-n5b94b: TOCTOU FIX - apply same sanitization logic to header
817/// names as header values to prevent asymmetric processing vulnerabilities.
818/// Header names with raw CR/LF would be rejected by the wire-format codec, but
819/// stripping them at the web layer is a defense-in-depth measure that ensures
820/// the response state is always serializable and matches the symmetric
821/// sanitization applied to header values.
822fn sanitize_header_name(name: String) -> String {
823    // Apply the same sanitization shape as header values for consistency.
824    // RFC 9110 field names are tokens: alphanumeric bytes plus the visible
825    // punctuation accepted in the match below.
826    name.bytes()
827        .filter(|&b| {
828            // Valid token bytes: ALPHA, DIGIT, and the allowed punctuation set.
829            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// ─── Tests ───────────────────────────────────────────────────────────────────
841
842#[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    /// br-asupersync-0hj233: Redirect::to MUST reject external URIs by
902    /// default; only relative paths and URIs in an explicit allow-list
903    /// (via to_with_allowed_hosts) are accepted. external_unchecked is
904    /// the explicit escape hatch.
905    #[test]
906    fn redirect_to_rejects_external_uri_by_default() {
907        // External http URL with arbitrary attacker host — REJECTED.
908        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        // External http URL — REJECTED.
915        let err = Redirect::to("http://attacker.com").unwrap_err();
916        assert!(matches!(err, RedirectError::HostNotAllowed { .. }));
917
918        // Same for permanent and temporary.
919        assert!(Redirect::permanent("https://attacker.com").is_err());
920        assert!(Redirect::temporary("https://attacker.com").is_err());
921    }
922
923    /// br-asupersync-0hj233: protocol-relative URLs '//attacker.com'
924    /// are the canonical bypass for naive starts_with('/') defenses
925    /// and MUST be rejected with the dedicated ProtocolRelative error
926    /// so the failure mode is debuggable.
927    #[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    /// br-asupersync-0hj233: backslash variant of the protocol-relative
937    /// bypass — some intermediaries normalize '\\' to '/' producing
938    /// '//attacker.com'. Reject the backslash form too.
939    #[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    /// br-asupersync-0hj233: javascript: / data: / file: schemes MUST
949    /// be rejected. Some browsers historically followed javascript:
950    /// URLs in Location headers, enabling stored-XSS-via-redirect.
951    #[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    /// br-asupersync-0hj233: empty URI is invalid.
968    #[test]
969    fn redirect_to_rejects_empty_uri() {
970        let err = Redirect::to("").unwrap_err();
971        assert!(matches!(err, RedirectError::EmptyUri));
972    }
973
974    /// br-asupersync-0hj233: relative paths with various edge-case
975    /// shapes are accepted.
976    #[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    /// br-asupersync-0hj233: to_with_allowed_hosts accepts absolute
994    /// URIs whose host is allow-listed and rejects others.
995    #[test]
996    fn redirect_to_with_allowed_hosts_accepts_listed_rejects_others() {
997        let allowed = &["example.com", "auth.example.com"];
998
999        // Listed host — accepted.
1000        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        // Case-insensitive host matching.
1009        assert!(Redirect::to_with_allowed_hosts("HTTPS://EXAMPLE.COM/", allowed).is_ok());
1010        // Relative path always accepted.
1011        assert!(Redirect::to_with_allowed_hosts("/local-path", allowed).is_ok());
1012
1013        // Unlisted host — rejected.
1014        let err =
1015            Redirect::to_with_allowed_hosts("https://attacker.com/phish", allowed).unwrap_err();
1016        assert!(matches!(err, RedirectError::HostNotAllowed { .. }));
1017
1018        // Subdomain not in allowlist — rejected (allowlist is exact match).
1019        let err =
1020            Redirect::to_with_allowed_hosts("https://evil.example.com/", allowed).unwrap_err();
1021        assert!(matches!(err, RedirectError::HostNotAllowed { .. }));
1022
1023        // Protocol-relative even with allowlist — still rejected.
1024        let err = Redirect::to_with_allowed_hosts("//example.com/path", allowed).unwrap_err();
1025        assert!(matches!(err, RedirectError::ProtocolRelative));
1026    }
1027
1028    /// br-asupersync-0hj233: external_unchecked is the explicit escape
1029    /// hatch for callers that genuinely need external redirects without
1030    /// an allowlist (e.g., dynamic OAuth providers). Verifies the API
1031    /// is reachable AND honors the URI verbatim.
1032    #[test]
1033    fn redirect_external_unchecked_accepts_arbitrary_uri() {
1034        // The whole point: NO validation — caller asserts trust.
1035        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    // =========================================================================
1123    // Wave 50 – pure data-type trait coverage
1124    // =========================================================================
1125
1126    #[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    // =========================================================================
1163    // CRLF injection defense
1164    // =========================================================================
1165
1166    #[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        // Invalid field-name bytes are stripped before lowercasing/insertion,
1219        // so the wire-format encoder never sees an injection vector or an
1220        // unserializable header name.
1221        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    /// br-asupersync-ehtkns: Set-Cookie is multi-valued; calling
1266    /// `set_header("set-cookie", X)` (or `.header()`) twice must
1267    /// preserve BOTH cookies. Before the fix, the HashMap-backed
1268    /// header store silently dropped the first cookie, which let
1269    /// SessionMiddleware overwrite handler-emitted CSRF / remember-me
1270    /// cookies.
1271    #[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        // Set-Cookie is NOT routed into the regular headers map.
1280        assert!(!resp.headers.contains_key("set-cookie"));
1281        // Case-insensitive header lookup still surfaces the first cookie
1282        // for backward compatibility with single-cookie callers.
1283        assert_eq!(
1284            resp.header_value("Set-Cookie"),
1285            Some("csrf=abc123; HttpOnly"),
1286        );
1287        assert!(resp.has_header("set-cookie"));
1288    }
1289
1290    /// br-asupersync-ehtkns: append_set_cookie still strips CR/LF
1291    /// from the cookie line so a malicious cookie value cannot smuggle
1292    /// a second header onto the wire.
1293    #[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    /// br-asupersync-ehtkns: removing the Set-Cookie header drains
1303    /// every queued cookie and surfaces the first as the legacy
1304    /// single-value return.
1305    #[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    // ====================================================================
1331    // br-asupersync-5jtjo0: header-value sanitiser allowlist tests
1332    // ====================================================================
1333
1334    #[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        // 0x01-0x08, 0x0B, 0x0C, 0x0E-0x1F all rejected.
1345        let raw: String = (0x01u8..=0x1F)
1346            .filter(|b| *b != 0x09) // HTAB stays
1347            .map(|b| b as char)
1348            .collect::<String>()
1349            + "trailing";
1350        let cleaned = sanitize_header_value(raw);
1351        // Only "trailing" survives — every C0 control was stripped.
1352        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        // UTF-8 codepoints whose bytes are >= 0x80 survive intact
1365        // (allowlist accepts 0x80..=0xFF as obs-text).
1366        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    // ====================================================================
1386    // br-asupersync-oms1b7: redirect protocol-relative + bypass tests
1387    // ====================================================================
1388
1389    // ====================================================================
1390    // br-asupersync-n5b94b: TOCTOU vulnerability fixes
1391    // ====================================================================
1392
1393    #[test]
1394    fn n5b94b_redirect_sanitization_matches_validation_strictness() {
1395        // Validation rejects control characters, final sanitization must too
1396        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        // Control characters must be stripped to match validation strictness
1401        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        // Header names should be sanitized consistently with values
1411        resp.set_header("x-test\r\n-header\x01", "value");
1412
1413        // Control characters should be stripped from header name
1414        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        // Add multiple case variants
1424        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        // set_header should atomically remove all case variants
1432        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        // Add header with non-normalized case
1454        resp.headers
1455            .insert("X-Custom".to_string(), "existing".to_string());
1456
1457        // ensure_header should preserve existing value atomically
1458        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"); // normalized case
1472        assert_eq!(custom_headers[0].1, "existing"); // preserved value
1473    }
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        // Without the byte-level prefilter, ` //attacker.com` could
1484        // bypass the `starts_with("//")` check on lenient browsers.
1485        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        // /%2fattacker.com would be normalised by some browsers to
1504        // //attacker.com after percent-decoding. Reject up front.
1505        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        // NUL is outside 0x21..=0x7E so caught by the byte prefilter.
1528        assert!(matches!(err, RedirectError::ProtocolRelative), "{err:?}");
1529    }
1530}