diff --git a/crates/rttp-client/src/response/raw_response.rs b/crates/rttp-client/src/response/raw_response.rs index 481882c5..d0e94587 100644 --- a/crates/rttp-client/src/response/raw_response.rs +++ b/crates/rttp-client/src/response/raw_response.rs @@ -249,6 +249,10 @@ impl Parser { let mut decoder = flate2::read::GzDecoder::new(binary.as_slice()); let mut buffer = Vec::new(); decoder.read_to_end(&mut buffer).map_err(error::decode)?; + response.headers.retain(|header| { + !header.name().eq_ignore_ascii_case("Content-Encoding") + && !header.name().eq_ignore_ascii_case("Content-Length") + }); let body = ResponseBody::new(buffer); response.body(body); return Ok(()); diff --git a/crates/rttp-client/tests/test_http_async.rs b/crates/rttp-client/tests/test_http_async.rs index 71318a5f..c3daf51c 100644 --- a/crates/rttp-client/tests/test_http_async.rs +++ b/crates/rttp-client/tests/test_http_async.rs @@ -19,6 +19,13 @@ use std::net::TcpListener; #[cfg(feature = "async")] use std::thread; +#[cfg(feature = "async")] +fn gzip_bytes(bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).expect("write gzip fixture"); + encoder.finish().expect("finish gzip fixture") +} + #[cfg(feature = "async")] fn client() -> HttpClient { HttpClient::new() @@ -111,6 +118,32 @@ fn test_async_http() { }); } +#[test] +#[cfg(feature = "async")] +fn test_async_buffered_gzip_response_exposes_decoded_body_headers() { + let body = gzip_bytes(b"decoded"); + let mut raw = format!( + "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + raw.extend_from_slice(&body); + let (addr, _handle) = support::spawn_chunked_response_server(raw); + + block_on(async { + let response = client() + .get() + .url(format!("http://{}/gzip", addr)) + .rasync() + .await + .expect("async buffered gzip response"); + + assert_eq!(b"decoded", response.body().binary()); + assert!(response.header("Content-Encoding").is_none()); + assert!(response.header("Content-Length").is_none()); + }); +} + #[test] #[cfg(feature = "async")] fn test_async_head_response_is_bodyless_and_preserves_headers() { diff --git a/crates/rttp-client/tests/test_http_basic.rs b/crates/rttp-client/tests/test_http_basic.rs index 9b94c0ae..e44e4bfa 100644 --- a/crates/rttp-client/tests/test_http_basic.rs +++ b/crates/rttp-client/tests/test_http_basic.rs @@ -7,6 +7,8 @@ use std::net::TcpListener; use std::thread; use std::time::Duration; +use flate2::write::GzEncoder; +use flate2::Compression; use rttp_client::types::{Auth, Para, Proxy, RoUrl}; use rttp_client::ConnectionReader; use rttp_client::{Config, HttpClient}; @@ -15,6 +17,12 @@ fn client() -> HttpClient { HttpClient::new() } +fn gzip_bytes(bytes: &[u8]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes).expect("write gzip fixture"); + encoder.finish().expect("finish gzip fixture") +} + fn spawn_streaming_upload_capture_server() -> (std::net::SocketAddr, thread::JoinHandle>) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind upload capture server"); let addr = listener.local_addr().expect("upload capture addr"); @@ -230,6 +238,28 @@ fn test_gzip() { assert!(response.is_ok()); } +#[test] +fn test_buffered_gzip_response_exposes_decoded_body_headers() { + let body = gzip_bytes(b"decoded"); + let mut raw = format!( + "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ) + .into_bytes(); + raw.extend_from_slice(&body); + let (addr, _handle) = support::spawn_chunked_response_server(raw); + + let response = client() + .get() + .url(format!("http://{}/gzip", addr)) + .emit() + .expect("buffered gzip response"); + + assert_eq!(b"decoded", response.body().binary()); + assert!(response.header("Content-Encoding").is_none()); + assert!(response.header("Content-Length").is_none()); +} + #[test] fn test_invalid_gzip_returns_error_instead_of_panicking() { let (addr, _handle) = support::spawn_invalid_gzip_server(); diff --git a/crates/rttp-client/tests/test_response.rs b/crates/rttp-client/tests/test_response.rs index 3e33b8d5..b90a2ca2 100644 --- a/crates/rttp-client/tests/test_response.rs +++ b/crates/rttp-client/tests/test_response.rs @@ -1344,14 +1344,50 @@ fn test_content_encoding_runtime_decodes_only_single_supported_gzip_coding() { .expect("single gzip response should decode"); assert_eq!("OK", response.body().string().unwrap()); + assert!(response.header("Content-Encoding").is_none()); + assert!(response.header("Content-Length").is_none()); + assert!(response.content_encoding().unwrap().is_none()); + assert!(response + .binary() + .windows(b"Content-Encoding: gzip".len()) + .any(|window| window == b"Content-Encoding: gzip")); +} + +#[test] +fn test_content_encoding_runtime_leaves_empty_gzip_body_and_headers_unchanged() { + let raw = concat!( + "HTTP/1.1 200 OK\r\n", + "Content-Encoding: gzip\r\n", + "Content-Length: 0\r\n", + "\r\n" + ); + let response = Response::new(RoUrl::with("https://example.test"), raw.as_bytes().to_vec()) + .expect("empty gzip response should remain usable"); + + assert!(response.body().binary().is_empty()); assert_eq!( - vec!["gzip"], + Some("gzip"), response - .content_encoding() - .expect("content-encoding should parse") - .expect("content-encoding should be present") - .codings() + .header_value("Content-Encoding") + .map(String::as_str) + ); + assert_eq!( + Some("0"), + response.header_value("Content-Length").map(String::as_str) + ); +} + +#[test] +fn test_content_encoding_runtime_rejects_malformed_single_gzip_body() { + let raw = concat!( + "HTTP/1.1 200 OK\r\n", + "Content-Encoding: gzip\r\n", + "Content-Length: 8\r\n", + "\r\n", + "not-gzip" ); + + assert!(Response::new(RoUrl::with("https://example.test"), raw.as_bytes().to_vec()).is_err()); } #[test] @@ -1377,6 +1413,34 @@ fn test_content_encoding_runtime_leaves_stacked_or_unsupported_codings_undecoded ); } +#[test] +fn test_content_encoding_runtime_leaves_duplicate_gzip_fields_undecoded() { + let body = gzip_bytes(b"OK"); + let mut raw = concat!( + "HTTP/1.1 200 OK\r\n", + "Content-Encoding: gzip\r\n", + "Content-Encoding: gzip\r\n" + ) + .as_bytes() + .to_vec(); + raw.extend_from_slice(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes()); + raw.extend_from_slice(&body); + + let response = Response::new(RoUrl::with("https://example.test"), raw) + .expect("duplicate gzip fields should remain usable"); + let content_length = body.len().to_string(); + + assert_eq!(body, response.body().binary()); + assert_eq!( + vec![&"gzip".to_string(), &"gzip".to_string()], + response.header_values("Content-Encoding") + ); + assert_eq!( + Some(content_length.as_str()), + response.header_value("Content-Length").map(String::as_str) + ); +} + #[test] fn test_parse_content_encoding_rejects_invalid_duplicate_and_excessive_values() { for value in [ diff --git a/tests/http11_client_server_matrix.rs b/tests/http11_client_server_matrix.rs index 833e3a7e..97f6ee49 100644 --- a/tests/http11_client_server_matrix.rs +++ b/tests/http11_client_server_matrix.rs @@ -1140,6 +1140,12 @@ fn assert_response_content_encoding( .into_iter() .map(String::as_str) .collect(); + if expected.values == ["gzip"] { + assert!(raw_values.is_empty(), "{name}"); + assert!(response.header("content-length").is_none(), "{name}"); + assert!(response.content_encoding().unwrap().is_none(), "{name}"); + return; + } assert_eq!(expected.values, raw_values.as_slice(), "{name}"); let content_encoding = response @@ -2439,27 +2445,41 @@ fn sync_client_observes_rttp_server_content_encoding_helpers() { .emit() .unwrap_or_else(|err| panic!("{} response should parse: {err}", case.name)); - let expected_header_value = case.codings.join(", "); - assert_eq!( - vec![expected_header_value.as_str()], - response - .header_values("Content-Encoding") - .into_iter() - .map(String::as_str) - .collect::>(), - "{}", - case.name - ); - let content_encoding = response - .content_encoding() - .unwrap_or_else(|err| panic!("{} Content-Encoding should parse: {err}", case.name)) - .unwrap_or_else(|| panic!("{} should include Content-Encoding", case.name)); - assert_eq!( - case.codings, - content_encoding.codings().as_slice(), - "{}", - case.name - ); + if case.codings == ["gzip"] { + assert!( + response.header("Content-Encoding").is_none(), + "{}", + case.name + ); + assert!(response.header("Content-Length").is_none(), "{}", case.name); + assert!( + response.content_encoding().unwrap().is_none(), + "{}", + case.name + ); + } else { + let expected_header_value = case.codings.join(", "); + assert_eq!( + vec![expected_header_value.as_str()], + response + .header_values("Content-Encoding") + .into_iter() + .map(String::as_str) + .collect::>(), + "{}", + case.name + ); + let content_encoding = response + .content_encoding() + .unwrap_or_else(|err| panic!("{} Content-Encoding should parse: {err}", case.name)) + .unwrap_or_else(|| panic!("{} should include Content-Encoding", case.name)); + assert_eq!( + case.codings, + content_encoding.codings().as_slice(), + "{}", + case.name + ); + } assert_eq!("OK", response.body().string().unwrap(), "{}", case.name); handle.join().expect("content-encoding server thread");