use crate::client::{build_request, follow_redirect, sanitize_redirect_headers, validate_protocol};
use crate::config::Config;
use crate::error::Error;
use crate::headers::Headers;
use crate::method::Method;
use crate::parser::uri::Uri;
use crate::parser::version::Version;
use crate::transport::RawResponse;
use alloc::borrow::Cow;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
extern crate alloc;
fn make_redirect_response(
status: u16,
location: &str,
) -> RawResponse {
let mut headers = Headers::new();
headers.insert("Location", location);
RawResponse {
status_code: status,
reason: String::from("Redirect"),
headers,
version: Version::HTTP_11,
body_bytes: bytes::Bytes::new(),
decoded_chunked_trailers: None,
}
}
fn raw_to_response_for_test(
raw: RawResponse,
method: &Method,
) -> crate::parser::Response {
let RawResponse {
status_code,
reason,
mut headers,
version,
body_bytes,
decoded_chunked_trailers,
} = raw;
let (body, trailers) = if method == &Method::Head {
(bytes::Bytes::new(), Headers::new())
} else if let Some(trailers) = decoded_chunked_trailers {
crate::parser::Response::finish_decoded_body(body_bytes, &mut headers, trailers, usize::MAX).unwrap()
} else {
crate::parser::Response::parse_body_from_owned(body_bytes, &mut headers, status_code, version, usize::MAX).unwrap()
};
crate::parser::Response::from_parts(status_code, reason, headers, body, trailers)
}
fn process(
config: &Config,
visited: &mut Vec<String>,
redirect_count: &mut u32,
raw: RawResponse,
current_url: &str,
method: &Method,
body: &mut Option<Vec<u8>>,
) -> Result<Option<(String, Method)>, Error> {
if config.http_status_as_error() && (400..600).contains(&raw.status_code) {
let response = raw_to_response_for_test(raw, method);
return Err(Error::HttpStatus(
response.status_code(),
alloc::boxed::Box::new(response),
));
}
let uri = Uri::parse(current_url).unwrap();
let response = raw_to_response_for_test(raw, method);
follow_redirect(
config,
visited,
redirect_count,
&response,
&uri,
current_url,
method,
body,
)
}
#[test]
fn https_only_policy_rejects_http() {
let config = Config::builder()
.https_only(true)
.assume_tls_socket(true)
.build();
let uri = Uri::parse("http://example.com").unwrap();
assert!(matches!(validate_protocol(&config, &uri), Err(Error::HttpsOnly)));
}
#[test]
fn https_only_policy_allows_https_with_tls_socket() {
let config = Config::builder()
.https_only(true)
.assume_tls_socket(true)
.build();
let uri = Uri::parse("https://example.com").unwrap();
assert!(validate_protocol(&config, &uri).is_ok());
}
#[test]
fn default_rejects_https_without_tls_socket() {
let uri = Uri::parse("https://example.com").unwrap();
assert!(matches!(
validate_protocol(&Config::default(), &uri),
Err(Error::TlsNotConfigured)
));
}
#[test]
fn assume_tls_socket_allows_https() {
let config = Config::builder().assume_tls_socket(true).build();
let uri = Uri::parse("https://example.com").unwrap();
assert!(validate_protocol(&config, &uri).is_ok());
}
#[test]
fn assume_tls_rejected_for_cleartext_os_client() {
use crate::HttpClient;
let client = HttpClient::with_config(Config::builder().assume_tls_socket(true).build());
let err = client
.get("https://example.com/")
.call()
.expect_err("cleartext OS socket must not claim TLS");
assert_eq!(err, Error::TlsNotConfigured);
}
#[test]
fn default_allows_http() {
let uri = Uri::parse("http://example.com").unwrap();
assert!(validate_protocol(&Config::default(), &uri).is_ok());
}
#[test]
fn policy_drops_body_for_head_requests() {
let mut headers = Headers::new();
headers.insert("Content-Length", "10");
let raw = RawResponse {
status_code: 200,
reason: String::from("OK"),
headers,
version: Version::HTTP_11,
body_bytes: bytes::Bytes::from_static(b"1234567890"),
decoded_chunked_trailers: None,
};
let resp = raw_to_response_for_test(raw, &Method::Head);
assert_eq!(resp.status_code(), 200);
assert!(resp.body().is_empty(), "HEAD response body should be empty");
}
#[test]
fn redirect_method_table_301_302_303() {
let statuses = [301_u16, 302, 303];
let cases: &[(Method, Option<Vec<u8>>, Method, bool)] = &[
(Method::Get, None, Method::Get, false),
(Method::Head, None, Method::Head, false),
(Method::Post, Some(vec![1]), Method::Get, true),
(Method::Put, Some(vec![1]), Method::Get, true),
(Method::Patch, Some(vec![1]), Method::Get, true),
(Method::Delete, None, Method::Get, true),
];
for status in statuses {
for (method, body, expect_method, drop_body) in cases {
let mut visited = Vec::new();
let mut count = 0;
let mut body = body.clone();
let next = process(
&Config::default(),
&mut visited,
&mut count,
make_redirect_response(status, "/next"),
"http://a.com",
method,
&mut body,
)
.unwrap()
.expect("expected redirect");
assert_eq!(next.1, *expect_method, "{method:?} {status} → method");
if *drop_body {
assert!(body.is_none(), "{method:?} {status} should drop body");
}
}
}
}
#[test]
fn redirect_method_table_307_308() {
for status in [307_u16, 308] {
for method in [Method::Get, Method::Head] {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let next = process(
&Config::default(),
&mut visited,
&mut count,
make_redirect_response(status, "/next"),
"http://a.com",
&method,
&mut body,
)
.unwrap()
.expect("expected redirect");
assert_eq!(next.1, method, "{method:?} {status} keeps method");
}
for method in [Method::Post, Method::Put, Method::Patch, Method::Delete] {
let mut visited = Vec::new();
let mut count = 0;
let mut body = if method == Method::Delete {
None
} else {
Some(vec![1, 2, 3])
};
let err = process(
&Config::default(),
&mut visited,
&mut count,
make_redirect_response(status, "/next"),
"http://a.com",
&method,
&mut body,
)
.unwrap_err();
assert!(
matches!(err, Error::RedirectFailed),
"{method:?} {status} → RedirectFailed"
);
}
}
}
#[test]
fn redirect_reuses_owned_body_allocation() {
let payload = vec![9_u8, 8, 7, 6, 5, 4, 3, 2, 1];
let ptr = payload.as_ptr();
let len = payload.len();
let mut visited = Vec::new();
let mut count = 0;
let mut body = Some(payload);
let next = process(
&Config::default(),
&mut visited,
&mut count,
make_redirect_response(307, "/next"),
"http://a.com",
&Method::Get,
&mut body,
)
.unwrap()
.expect("expected redirect");
assert_eq!(next.1, Method::Get);
let kept = body.expect("307 GET keeps body");
assert_eq!(kept.as_ptr(), ptr, "redirect must reuse owned body buffer");
assert_eq!(kept.len(), len);
}
#[test]
fn non_followable_3xx_is_returned() {
for status in [300_u16, 304, 305, 306, 399] {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let mut headers = Headers::new();
headers.insert("Location", "/next");
let raw = RawResponse {
status_code: status,
reason: String::from("x"),
headers,
version: Version::HTTP_11,
body_bytes: bytes::Bytes::new(),
decoded_chunked_trailers: None,
};
assert!(
process(
&Config::default(),
&mut visited,
&mut count,
raw,
"http://a.com",
&Method::Get,
&mut body,
)
.unwrap()
.is_none(),
"status {status} must not be followed"
);
}
}
#[test]
fn get_redirect_stays_get() {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let next = process(
&Config::default(),
&mut visited,
&mut count,
make_redirect_response(302, "/next"),
"http://a.com",
&Method::Get,
&mut body,
)
.unwrap()
.expect("expected redirect");
assert_eq!(next.1, Method::Get);
}
#[test]
fn redirect_loop_is_detected() {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let config = Config::default();
let raw = make_redirect_response(301, "http://a.com");
process(
&config,
&mut visited,
&mut count,
raw.clone(),
"http://a.com",
&Method::Get,
&mut body,
)
.unwrap();
let err = process(
&config,
&mut visited,
&mut count,
raw,
"http://a.com",
&Method::Get,
&mut body,
)
.unwrap_err();
assert!(matches!(err, Error::RedirectLoop));
}
#[test]
fn status_error_when_configured() {
for status in [404_u16, 500] {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let config = Config::builder().http_status_as_error(true).build();
let mut headers = Headers::new();
headers.insert("X-Err", "yes");
let raw = RawResponse {
status_code: status,
reason: String::from("err"),
headers,
version: Version::HTTP_11,
body_bytes: bytes::Bytes::from_static(b"fail"),
decoded_chunked_trailers: None,
};
let err = process(
&config,
&mut visited,
&mut count,
raw,
"http://example.com",
&Method::Get,
&mut body,
)
.unwrap_err();
match err {
Error::HttpStatus(code, resp) => {
assert_eq!(code, status);
assert_eq!(resp.status_code(), status);
assert_eq!(resp.body(), b"fail");
assert_eq!(resp.header("X-Err"), Some("yes"));
},
other => panic!("expected HttpStatus, got {other:?}"),
}
}
}
#[test]
fn status_4xx_is_ok_when_configured_as_response() {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let config = Config::builder().http_status_as_error(false).build();
let raw = RawResponse {
status_code: 404,
reason: String::from("Not Found"),
headers: Headers::new(),
version: Version::HTTP_11,
body_bytes: bytes::Bytes::new(),
decoded_chunked_trailers: None,
};
assert!(
process(
&config,
&mut visited,
&mut count,
raw,
"http://example.com",
&Method::Get,
&mut body,
)
.unwrap()
.is_none()
);
}
#[test]
fn too_many_redirects_is_error() {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let config = Config::builder().max_redirects(2).build();
let raw = make_redirect_response(301, "/next");
process(
&config,
&mut visited,
&mut count,
raw.clone(),
"http://a.com",
&Method::Get,
&mut body,
)
.unwrap();
process(
&config,
&mut visited,
&mut count,
raw.clone(),
"http://b.com",
&Method::Get,
&mut body,
)
.unwrap();
let err = process(
&config,
&mut visited,
&mut count,
raw,
"http://c.com",
&Method::Get,
&mut body,
)
.unwrap_err();
assert!(matches!(err, Error::TooManyRedirects));
}
#[test]
fn same_origin_redirect_follows() {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let next = process(
&Config::default(),
&mut visited,
&mut count,
make_redirect_response(302, "/next"),
"http://a.com/path",
&Method::Get,
&mut body,
)
.unwrap()
.expect("expected redirect");
assert_eq!(next.0, "http://a.com/next");
}
#[test]
fn cross_origin_redirect_follows() {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let next = process(
&Config::default(),
&mut visited,
&mut count,
make_redirect_response(302, "http://b.com/next"),
"http://a.com/path",
&Method::Get,
&mut body,
)
.unwrap()
.expect("expected redirect");
assert_eq!(next.0, "http://b.com/next");
}
#[test]
fn different_port_redirect_follows() {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let next = process(
&Config::default(),
&mut visited,
&mut count,
make_redirect_response(302, "http://a.com:9090/next"),
"http://a.com:8080/path",
&Method::Get,
&mut body,
)
.unwrap()
.expect("expected redirect");
assert_eq!(next.0, "http://a.com:9090/next");
}
#[test]
fn sanitize_strips_credentials_on_every_hop() {
let mut headers = Headers::new();
headers.insert("Authorization", "Bearer secret");
headers.insert("Cookie", "sid=1");
headers.insert("Connection", "keep-alive");
headers.insert("Content-Length", "3");
headers.insert("Host", "old.example.com");
headers.insert("X-Custom", "keep");
sanitize_redirect_headers(&mut headers, false);
assert!(!headers.contains("Authorization"));
assert!(!headers.contains("Cookie"));
assert!(!headers.contains("Connection"));
assert!(!headers.contains("Content-Length"));
assert!(!headers.contains("Host"));
assert_eq!(headers.get("X-Custom"), Some("keep"));
let mut drop_body = Headers::new();
drop_body.insert("Content-Length", "99");
drop_body.insert("Content-Type", "application/json");
sanitize_redirect_headers(&mut drop_body, true);
assert!(!drop_body.contains("Content-Length"));
assert!(!drop_body.contains("Content-Type"));
}
#[test]
fn max_redirects_zero_does_not_follow() {
let mut visited = Vec::new();
let mut count = 0;
let mut body = None;
let config = Config::builder().max_redirects(0).build();
assert!(
process(
&config,
&mut visited,
&mut count,
make_redirect_response(302, "/next"),
"http://a.com",
&Method::Get,
&mut body,
)
.unwrap()
.is_none()
);
}
#[test]
fn chunked_trailers_reach_response() {
let mut headers = Headers::new();
headers.insert("Transfer-Encoding", "chunked");
let raw = RawResponse {
status_code: 200,
reason: String::from("OK"),
headers,
version: Version::HTTP_11,
body_bytes: bytes::Bytes::from_static(b"5\r\nhello\r\n0\r\nX-Trailer: value\r\n\r\n"),
decoded_chunked_trailers: None,
};
let resp = raw_to_response_for_test(raw, &Method::Get);
assert_eq!(resp.body(), b"hello");
assert_eq!(resp.trailers().get("X-Trailer"), Some("value"));
assert_eq!(resp.trailers().len(), 1);
}
#[test]
fn build_request_is_http11_with_host_and_origin_form() {
let uri = Uri::parse("http://example.com:8080/a/b?q=1").unwrap();
let mut host = Cow::Borrowed("example.com");
let mut headers = Headers::new();
let bytes = build_request(
&uri,
&Method::Get,
&mut host,
8080,
&mut headers,
None,
&Config::default(),
)
.unwrap()
.to_bytes();
let text = String::from_utf8_lossy(&bytes);
assert!(text.starts_with("GET /a/b?q=1 HTTP/1.1\r\n"));
assert!(text.contains("Host: example.com:8080\r\n"));
assert!(!text.contains("http://example.com"));
assert!(!text.to_ascii_lowercase().contains("connection:"));
}
#[test]
fn build_request_sends_connection_close_when_pooling_disabled() {
let uri = Uri::parse("http://example.com/").unwrap();
let config = Config::builder().max_idle_per_host(0).build();
let mut host = Cow::Borrowed("example.com");
let mut custom = Headers::new();
custom.insert("Connection", "keep-alive");
let bytes = build_request(&uri, &Method::Get, &mut host, 80, &mut custom, None, &config)
.unwrap()
.to_bytes();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("Connection: close\r\n"));
assert!(!text.contains("keep-alive"));
}
#[test]
fn build_request_default_port_omits_port_in_host() {
let uri = Uri::parse("http://example.com/path").unwrap();
let mut host = Cow::Borrowed("example.com");
let mut headers = Headers::new();
let bytes = build_request(
&uri,
&Method::Get,
&mut host,
80,
&mut headers,
None,
&Config::default(),
)
.unwrap()
.to_bytes();
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("Host: example.com\r\n"));
assert!(!text.contains("Host: example.com:80"));
}