net-cat 0.3.0

Minimal hand-rolled HTTP/1.1 client over std::net::TcpStream. v0.3.0 adds chunked-transfer decoding (`Transfer-Encoding: chunked` responses now yield the correct decoded body) and a redirect follower in `fetch` (RFC 7231 §6.4: 301/302/303 downgrade non-GET/HEAD to GET and drop the body; 307/308 preserve method + body; cross-origin hops strip `Cookie` and `Authorization`; capped at `MAX_REDIRECTS = 10` hops). Optional `tls` feature still wires rustls + webpki-roots for `https://` URLs. No external HTTP crate. Sixth sub-crate of a Servo-replacement webview runtime targeting Tauri.
//! `fetch` -- the high-level entry point.
//!
//! v0.3 wraps `transport::exchange` in a redirect follower.  When the
//! server replies with a 3xx status carrying a `Location` header,
//! `fetch` builds a follow-up request per RFC 7231 §6.4 (301/302/303
//! downgrade to `GET` and drop the body; 307/308 preserve method +
//! body) and re-issues it.  Cross-origin hops (where the new host
//! differs from the previous host) strip the `Cookie` and
//! `Authorization` headers from the follow-up request so credentials
//! don't leak across domains.  Hop count is capped by
//! [`MAX_REDIRECTS`].

use crate::error::Error;
use crate::headers::Headers;
use crate::method::Method;
use crate::request::Request;
use crate::response::Response;
use crate::transport;
use crate::url::Url;

/// Hard cap on redirect hops a single `fetch` call will follow before
/// returning [`Error::TooManyRedirects`].  Matches the de-facto
/// browser default of 10.
pub const MAX_REDIRECTS: u8 = 10;

/// Execute `request` and return the parsed response, following
/// redirects up to [`MAX_REDIRECTS`] hops.
///
/// # Errors
///
/// See [`Error`].  In particular `https://` URLs return
/// [`Error::UnsupportedScheme`] unless the `tls` feature is enabled;
/// a redirect chain longer than [`MAX_REDIRECTS`] returns
/// [`Error::TooManyRedirects`]; an absolute `Location` URL that
/// fails to parse returns [`Error::InvalidUrl`].
pub fn fetch(request: &Request) -> Result<Response, Error> {
    follow(request, MAX_REDIRECTS)
}

fn follow(request: &Request, hops_remaining: u8) -> Result<Response, Error> {
    let response = transport::exchange(request)?;
    match redirect_target(request, &response) {
        Some(next) => match hops_remaining {
            0 => Err(Error::TooManyRedirects),
            remaining => follow(&next, remaining - 1),
        },
        None => Ok(response),
    }
}

fn redirect_target(request: &Request, response: &Response) -> Option<Request> {
    redirect_method(response.status(), request.method())
        .zip(response.headers().get("location"))
        .and_then(|(next_method, location)| build_next(request, next_method, location))
}

fn redirect_method(status: u16, current: Method) -> Option<Method> {
    match status {
        301..=303 => Some(redirect_get_downgrade(current)),
        307 | 308 => Some(current),
        _other => None,
    }
}

fn redirect_get_downgrade(current: Method) -> Method {
    // 301/302/303 historically downgrade non-GET/HEAD requests to
    // GET (per RFC 7231 §6.4.{2,3,4}); 303 explicitly mandates it.
    match current {
        Method::Head => Method::Head,
        Method::Get
        | Method::Post
        | Method::Put
        | Method::Delete
        | Method::Options
        | Method::Patch => Method::Get,
    }
}

fn build_next(request: &Request, method: Method, location: &str) -> Option<Request> {
    let next_url = Url::parse(location).ok()?;
    let cross_origin = next_url.host() != request.url().host();
    let next_headers = sanitize_headers(request.headers().clone(), cross_origin, method);
    let next_body = redirect_body(request, method);
    Some(
        request
            .clone()
            .with_url(next_url)
            .with_method(method)
            .with_headers(next_headers)
            .with_body(next_body),
    )
}

fn sanitize_headers(headers: Headers, cross_origin: bool, next_method: Method) -> Headers {
    let without_credentials = if cross_origin {
        headers.without("Cookie").without("Authorization")
    } else {
        headers
    };
    match next_method {
        Method::Get | Method::Head => without_credentials.without("Content-Length"),
        Method::Post | Method::Put | Method::Delete | Method::Options | Method::Patch => {
            without_credentials
        }
    }
}

fn redirect_body(request: &Request, next_method: Method) -> Vec<u8> {
    match next_method {
        Method::Get | Method::Head => Vec::new(),
        Method::Post | Method::Put | Method::Delete | Method::Options | Method::Patch => {
            request.body().to_vec()
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn fail() -> Error {
        Error::InvalidStatusLine {
            text: "test predicate failed".to_owned(),
        }
    }

    fn dummy_request() -> Result<Request, Error> {
        Ok(Request::new(Method::Get, Url::parse("http://a.example/")?)
            .with_header("Cookie", "session=abc")
            .with_header("Authorization", "Bearer x")
            .with_header("X-Trace", "keep-me"))
    }

    #[test]
    fn redirect_method_303_downgrades_post_to_get() -> Result<(), Error> {
        let downgraded = redirect_method(303, Method::Post);
        matches!(downgraded, Some(Method::Get))
            .then_some(())
            .ok_or_else(fail)
    }

    #[test]
    fn redirect_method_307_preserves_post() -> Result<(), Error> {
        let preserved = redirect_method(307, Method::Post);
        matches!(preserved, Some(Method::Post))
            .then_some(())
            .ok_or_else(fail)
    }

    #[test]
    fn redirect_method_200_returns_none() -> Result<(), Error> {
        redirect_method(200, Method::Get)
            .is_none()
            .then_some(())
            .ok_or_else(fail)
    }

    #[test]
    fn build_next_cross_origin_strips_cookie_and_auth() -> Result<(), Error> {
        let request = dummy_request()?;
        let next = build_next(&request, Method::Get, "http://b.example/other").ok_or_else(fail)?;
        let has_cookie = next.headers().get("Cookie").is_some();
        let has_auth = next.headers().get("Authorization").is_some();
        let has_trace = next.headers().get("X-Trace").is_some();
        (!has_cookie && !has_auth && has_trace)
            .then_some(())
            .ok_or_else(fail)
    }

    #[test]
    fn build_next_same_origin_keeps_cookie_and_auth() -> Result<(), Error> {
        let request = dummy_request()?;
        let next =
            build_next(&request, Method::Get, "http://a.example/elsewhere").ok_or_else(fail)?;
        let has_cookie = next.headers().get("Cookie").is_some();
        let has_auth = next.headers().get("Authorization").is_some();
        (has_cookie && has_auth).then_some(()).ok_or_else(fail)
    }

    #[test]
    fn build_next_get_drops_body() -> Result<(), Error> {
        let request = Request::new(Method::Post, Url::parse("http://a.example/")?)
            .with_body(b"hello".to_vec());
        let next =
            build_next(&request, Method::Get, "http://a.example/landing").ok_or_else(fail)?;
        next.body().is_empty().then_some(()).ok_or_else(fail)
    }

    #[test]
    fn build_next_307_preserves_body() -> Result<(), Error> {
        let request = Request::new(Method::Post, Url::parse("http://a.example/")?)
            .with_body(b"hello".to_vec());
        let next =
            build_next(&request, Method::Post, "http://a.example/landing").ok_or_else(fail)?;
        (next.body() == b"hello").then_some(()).ok_or_else(fail)
    }

    #[test]
    fn build_next_invalid_location_returns_none() -> Result<(), Error> {
        let request = dummy_request()?;
        build_next(&request, Method::Get, "not a url")
            .is_none()
            .then_some(())
            .ok_or_else(fail)
    }
}