makepad/platform/network/tests/blocking_http.rs
Admin 2a46d2a405 Land platform, studio, and widget infra from rik2.
HTTP progress, OS file drag, RunView controllers, DropDown2, video,
remote process helpers, and the Studio runbook.
2026-08-18 14:23:57 +02:00

666 lines
22 KiB
Rust

//! Local-TCP tests for the no-redirect blocking HTTP client.
//! No external hosts are contacted.
use makepad_network::blocking_http::{
post_json, request_no_redirect, CancelToken, Error, Limits, Request,
};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
fn listen() -> (TcpListener, u16) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
let port = listener.local_addr().expect("local addr").port();
(listener, port)
}
fn read_request(stream: &mut TcpStream) -> Vec<u8> {
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let mut buf = Vec::new();
let mut tmp = [0u8; 2048];
loop {
if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
let head = &buf[..pos + 4];
let head_s = String::from_utf8_lossy(head);
let mut content_len = 0usize;
for line in head_s.split("\r\n") {
let lower = line.to_ascii_lowercase();
if let Some(v) = lower.strip_prefix("content-length:") {
content_len = v.trim().parse().unwrap_or(0);
}
}
let body_have = buf.len() - (pos + 4);
if body_have >= content_len {
return buf;
}
}
match stream.read(&mut tmp) {
Ok(0) => return buf,
Ok(n) => buf.extend_from_slice(&tmp[..n]),
Err(_) => return buf,
}
if buf.len() > 64 * 1024 {
return buf;
}
}
}
fn serve_one(listener: TcpListener, response: Vec<u8>) -> thread::JoinHandle<Vec<u8>> {
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let req = read_request(&mut stream);
let _ = stream.write_all(&response);
let _ = stream.flush();
req
})
}
fn serve_hang_after_accept(listener: TcpListener) -> (thread::JoinHandle<()>, Arc<Mutex<bool>>) {
let accepted = Arc::new(Mutex::new(false));
let flag = accepted.clone();
let handle = thread::spawn(move || {
if let Ok((stream, _)) = listener.accept() {
*flag.lock().unwrap() = true;
thread::sleep(Duration::from_secs(1));
drop(stream);
}
});
(handle, accepted)
}
fn url(port: u16, path: &str) -> String {
format!("http://127.0.0.1:{port}{path}")
}
fn tight_limits() -> Limits {
Limits {
max_head_bytes: 512,
max_header_count: 8,
max_header_line_bytes: 128,
max_trailer_count: 2,
max_trailer_bytes: 64,
max_body_bytes: 64,
max_chunk_line_bytes: 16,
total_timeout: Duration::from_secs(3),
}
}
#[test]
fn post_json_serializes_request_without_reserved_caller_headers() {
let (listener, port) = listen();
let handle = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".to_vec(),
);
let body = br#"{"model":"gpt-5.6"}"#.to_vec();
let req = Request::post(url(port, "/v1/responses"))
.bearer("test-token-abc")
.unwrap()
.json_body(body.clone())
.unwrap();
let resp = post_json(req).expect("post");
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"ok");
let raw = String::from_utf8(handle.join().unwrap()).unwrap();
assert!(raw.starts_with("POST /v1/responses HTTP/1.1\r\n"), "{raw}");
assert!(raw.contains("Host: 127.0.0.1:"), "{raw}");
assert!(raw.contains("Authorization: Bearer test-token-abc"), "{raw}");
assert!(raw.contains("Content-Type: application/json"), "{raw}");
assert!(raw.contains(&format!("Content-Length: {}\r\n", body.len())), "{raw}");
assert!(raw.contains("Connection: close"), "{raw}");
assert_eq!(raw.matches("Content-Length:").count(), 1);
assert!(!raw.to_ascii_lowercase().contains("transfer-encoding"));
assert!(raw.contains("{\"model\":\"gpt-5.6\"}"), "{raw}");
}
fn expect_header_err(result: Result<Request, Error>, want: Error) {
match result {
Err(e) => assert_eq!(e, want),
Ok(_) => panic!("expected {want:?}"),
}
}
fn expect_err(result: Result<makepad_network::blocking_http::Response, Error>, want: Error) -> Error {
match result {
Err(e) => {
assert_eq!(e, want);
e
}
Ok(_) => panic!("expected {want:?}"),
}
}
#[test]
fn caller_cannot_set_reserved_or_injected_headers() {
expect_header_err(Request::post("http://127.0.0.1:1/").header("Host", "evil"), Error::ReservedHeader);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Content-Length", "9"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Transfer-Encoding", "chunked"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Connection", "keep-alive"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("X-A", "line\r\nX-Injected: 1"),
Error::InvalidHeader,
);
expect_header_err(Request::post("http://127.0.0.1:1/").header("X-A\nB", "1"), Error::InvalidHeader);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("User-Agent", "evil"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Accept-Encoding", "gzip"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Accept", "application/json"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Expect", "100-continue"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("TE", "trailers"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Trailer", "Expires"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Upgrade", "websocket"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("Proxy-Authorization", "Basic x"),
Error::ReservedHeader,
);
expect_header_err(
Request::post("http://127.0.0.1:1/").header("X-A", "has\u{01}control"),
Error::InvalidHeader,
);
let dup = Request::post("http://127.0.0.1:1/")
.header("X-One", "a")
.unwrap()
.header("x-one", "b");
expect_header_err(dup, Error::InvalidHeader);
}
#[test]
fn cleartext_non_loopback_is_refused() {
let err = expect_err(request_no_redirect(Request::get("http://example.com/")), Error::CleartextForbidden);
assert!(!err.to_string().contains("example.com"));
}
#[test]
fn redirect_is_refused_and_never_contacts_sink_with_bearer() {
let sink_hits = Arc::new(Mutex::new(0u32));
let (sink, sink_port) = listen();
let hits = sink_hits.clone();
let sink_thread = thread::spawn(move || {
let _ = sink.set_nonblocking(true);
let start = std::time::Instant::now();
while start.elapsed() < Duration::from_millis(400) {
if sink.accept().is_ok() {
*hits.lock().unwrap() += 1;
}
thread::sleep(Duration::from_millis(10));
}
});
let (origin, origin_port) = listen();
let location = format!("http://127.0.0.1:{sink_port}/stolen");
let response = format!(
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\n\r\n"
);
let origin_thread = serve_one(origin, response.into_bytes());
let err = expect_err(
post_json(
Request::post(url(origin_port, "/v1/responses"))
.bearer("super-secret-bearer")
.unwrap()
.json_body(b"{}".to_vec())
.unwrap(),
),
Error::RedirectRefused,
);
assert!(!err.to_string().contains("super-secret-bearer"));
let seen = String::from_utf8(origin_thread.join().unwrap()).unwrap();
assert!(seen.contains("Authorization: Bearer super-secret-bearer"));
thread::sleep(Duration::from_millis(80));
assert_eq!(*sink_hits.lock().unwrap(), 0, "sink must never be contacted");
let _ = sink_thread.join();
}
#[test]
fn request_no_redirect_returns_3xx_without_following() {
let (sink, sink_port) = listen();
let sink_hits = Arc::new(Mutex::new(0u32));
let hits = sink_hits.clone();
thread::spawn(move || {
if sink.accept().is_ok() {
*hits.lock().unwrap() += 1;
}
});
let (origin, origin_port) = listen();
let location = format!("http://127.0.0.1:{sink_port}/next");
let origin_thread = serve_one(
origin,
format!("HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\n\r\n")
.into_bytes(),
);
let resp = request_no_redirect(Request::get(url(origin_port, "/go"))).expect("one hop");
assert_eq!(resp.status, 307);
assert_eq!(resp.header("location"), Some(location.as_str()));
let _ = origin_thread.join();
thread::sleep(Duration::from_millis(50));
assert_eq!(*sink_hits.lock().unwrap(), 0);
}
#[test]
fn small_head_with_coalesced_body_is_accepted() {
let (listener, port) = listen();
let mut resp = b"HTTP/1.1 200 OK\r\nContent-Length: 200\r\n\r\n".to_vec();
resp.extend(std::iter::repeat(b'x').take(200));
let _ = serve_one(listener, resp);
let mut limits = tight_limits();
limits.max_head_bytes = 64;
limits.max_body_bytes = 256;
let got = request_no_redirect(Request::get(url(port, "/")).limits(limits)).expect("coalesced");
assert_eq!(got.status, 200);
assert_eq!(got.body.len(), 200);
}
#[test]
fn oversized_head_is_rejected() {
let (listener, port) = listen();
let mut resp = b"HTTP/1.1 200 OK\r\n".to_vec();
for i in 0..40 {
resp.extend_from_slice(format!("X-{i}: {}\r\n", "a".repeat(20)).as_bytes());
}
resp.extend_from_slice(b"Content-Length: 0\r\n\r\n");
let _ = serve_one(listener, resp);
expect_err(
request_no_redirect(Request::get(url(port, "/")).limits(tight_limits())),
Error::ResponseTooLarge,
);
}
#[test]
fn oversized_body_is_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nContent-Length: 200\r\n\r\n".to_vec(),
);
expect_err(
request_no_redirect(Request::get(url(port, "/")).limits(tight_limits())),
Error::ResponseTooLarge,
);
}
#[test]
fn oversized_chunk_line_is_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0123456789abcdef0\r\n".to_vec(),
);
expect_err(
request_no_redirect(Request::get(url(port, "/")).limits(tight_limits())),
Error::ResponseTooLarge,
);
}
#[test]
fn invalid_chunk_crlf_is_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWikiXX0\r\n\r\n".to_vec(),
);
expect_err(request_no_redirect(Request::get(url(port, "/"))), Error::InvalidResponse);
}
#[test]
fn transfer_encoding_with_content_length_is_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Length: 4\r\n\r\n4\r\nWiki\r\n0\r\n\r\n"
.to_vec(),
);
expect_err(request_no_redirect(Request::get(url(port, "/"))), Error::InvalidResponse);
}
#[test]
fn duplicate_content_length_is_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\nContent-Length: 4\r\n\r\nxxxx".to_vec(),
);
expect_err(request_no_redirect(Request::get(url(port, "/"))), Error::InvalidResponse);
}
#[test]
fn malformed_content_length_is_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nContent-Length: 12abc\r\n\r\n".to_vec(),
);
expect_err(request_no_redirect(Request::get(url(port, "/"))), Error::InvalidResponse);
}
#[test]
fn unsupported_transfer_encoding_is_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\n\r\n".to_vec(),
);
expect_err(
request_no_redirect(Request::get(url(port, "/"))),
Error::UnsupportedTransferEncoding,
);
}
#[test]
fn chunked_body_roundtrip() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n5\r\npedia\r\n0\r\n\r\n"
.to_vec(),
);
let resp = request_no_redirect(Request::get(url(port, "/"))).expect("chunked");
assert_eq!(resp.body, b"Wikipedia");
}
#[test]
fn cancel_returns_without_waiting_for_body() {
let (listener, port) = listen();
let (hang, accepted) = serve_hang_after_accept(listener);
let token = CancelToken::new();
let token2 = token.clone();
let url = url(port, "/slow");
let worker = thread::spawn(move || {
request_no_redirect(Request::get(url).cancel_token(token2).limits(Limits {
total_timeout: Duration::from_secs(5),
..Limits::default()
}))
});
let start = std::time::Instant::now();
while !*accepted.lock().unwrap() && start.elapsed() < Duration::from_secs(2) {
thread::sleep(Duration::from_millis(5));
}
assert!(*accepted.lock().unwrap(), "server should have accepted");
token.cancel();
expect_err(worker.join().unwrap(), Error::Cancelled);
assert!(start.elapsed() < Duration::from_secs(3));
let _ = hang.join();
}
#[test]
fn framing_trailers_and_trailing_bytes_are_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n0\r\nContent-Length: 4\r\n\r\n"
.to_vec(),
);
expect_err(request_no_redirect(Request::get(url(port, "/"))), Error::InvalidResponse);
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n0\r\n\r\nGARBAGE".to_vec(),
);
expect_err(request_no_redirect(Request::get(url(port, "/"))), Error::InvalidResponse);
}
#[test]
fn close_delimited_clean_eof_succeeds() {
let (listener, port) = listen();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let _ = read_request(&mut stream);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhello");
let _ = stream.flush();
drop(stream);
});
let resp = request_no_redirect(Request::get(url(port, "/"))).expect("clean eof");
assert_eq!(resp.body, b"hello");
let _ = handle.join();
}
#[cfg(unix)]
#[test]
fn close_delimited_reset_is_not_success() {
let (listener, port) = listen();
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().expect("accept");
let mut stream = stream;
let _ = read_request(&mut stream);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhe");
let _ = stream.flush();
rst_stream(stream);
});
let err = match request_no_redirect(Request::get(url(port, "/"))) {
Err(e) => e,
Ok(_) => panic!("reset must not parse as a clean body"),
};
assert!(err == Error::Reset || err == Error::Io, "{err:?}");
let _ = handle.join();
}
#[cfg(unix)]
fn rst_stream(stream: TcpStream) {
#[repr(C)]
struct Linger {
l_onoff: i32,
l_linger: i32,
}
#[cfg(target_os = "macos")]
const SOL_SOCKET: i32 = 0xffff;
#[cfg(target_os = "macos")]
const SO_LINGER: i32 = 0x80;
#[cfg(target_os = "linux")]
const SOL_SOCKET: i32 = 1;
#[cfg(target_os = "linux")]
const SO_LINGER: i32 = 13;
unsafe extern "C" {
fn setsockopt(
socket: i32,
level: i32,
name: i32,
value: *const Linger,
len: u32,
) -> i32;
}
use std::os::fd::AsRawFd;
let linger = Linger { l_onoff: 1, l_linger: 0 };
unsafe {
let _ = setsockopt(
stream.as_raw_fd(),
SOL_SOCKET,
SO_LINGER,
&linger,
std::mem::size_of::<Linger>() as u32,
);
}
drop(stream);
}
#[test]
fn errors_do_not_embed_bearer_or_body() {
let err = Request::post("http://127.0.0.1:1/")
.bearer("sk-live-secret-value")
.unwrap()
.json_body(br#"{"prompt":"do not leak"}"#.to_vec())
.unwrap()
.header("X-Custom", "nope")
.unwrap();
let fail = match post_json(err) {
Err(e) => e,
Ok(_) => panic!("expected connect failure"),
};
let text = fail.to_string();
assert!(!text.contains("sk-live-secret-value"), "{text}");
assert!(!text.contains("do not leak"), "{text}");
assert!(!text.contains("Authorization"), "{text}");
assert!(!text.contains("Bearer"), "{text}");
}
#[test]
fn url_rejects_userinfo_fragment_space_and_controls() {
expect_err(
request_no_redirect(Request::get("http://user:pass@127.0.0.1/")),
Error::InvalidUrl,
);
expect_err(
request_no_redirect(Request::get("http://127.0.0.1/path#frag")),
Error::InvalidUrl,
);
expect_err(
request_no_redirect(Request::get("http://127.0.0.1/pa th")),
Error::InvalidUrl,
);
expect_err(
request_no_redirect(Request::get("http://127.0.0.1/pa\nth")),
Error::InvalidUrl,
);
}
#[test]
fn ipv6_loopback_host_is_bracketed() {
let listener = TcpListener::bind("[::1]:0").expect("bind v6");
let port = listener.local_addr().expect("addr").port();
let handle = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".to_vec(),
);
let resp = request_no_redirect(Request::get(format!("http://[::1]:{port}/"))).expect("v6");
assert_eq!(resp.body, b"ok");
let raw = String::from_utf8(handle.join().unwrap()).unwrap();
assert!(raw.contains("Host: [::1]"), "{raw}");
}
#[test]
fn informational_1xx_is_consumed_not_final() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".to_vec(),
);
let resp = request_no_redirect(Request::get(url(port, "/"))).expect("1xx then 200");
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"ok");
}
#[test]
fn space_before_header_colon_is_rejected() {
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nX-Foo : bar\r\nContent-Length: 0\r\n\r\n".to_vec(),
);
expect_err(request_no_redirect(Request::get(url(port, "/"))), Error::InvalidResponse);
}
#[test]
fn header_then_stall_cancel_returns_promptly() {
let (listener, port) = listen();
let accepted = Arc::new(Mutex::new(false));
let flag = accepted.clone();
let hang = thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
*flag.lock().unwrap() = true;
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n");
let _ = stream.flush();
thread::sleep(Duration::from_secs(4));
}
});
let token = CancelToken::new();
let token2 = token.clone();
let u = url(port, "/stall");
let worker = thread::spawn(move || {
request_no_redirect(Request::get(u).cancel_token(token2).limits(Limits {
total_timeout: Duration::from_secs(5),
..Limits::default()
}))
});
let start = std::time::Instant::now();
while !*accepted.lock().unwrap() && start.elapsed() < Duration::from_secs(2) {
thread::sleep(Duration::from_millis(5));
}
token.cancel();
expect_err(worker.join().unwrap(), Error::Cancelled);
assert!(start.elapsed() < Duration::from_secs(3));
let _ = hang.join();
}
#[test]
fn delayed_trailing_bytes_after_chunked_are_rejected() {
let (listener, port) = listen();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept");
let _ = read_request(&mut stream);
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n4\r\nWiki\r\n0\r\n\r\n",
);
let _ = stream.flush();
thread::sleep(Duration::from_millis(30));
let _ = stream.write_all(b"GARBAGE");
let _ = stream.flush();
});
expect_err(request_no_redirect(Request::get(url(port, "/"))), Error::InvalidResponse);
let _ = handle.join();
}
#[test]
fn trailer_count_cap_is_enforced() {
let (listener, port) = listen();
let mut body = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n".to_vec();
for i in 0..8 {
body.extend_from_slice(format!("X-T{i}: v\r\n").as_bytes());
}
body.extend_from_slice(b"\r\n");
let _ = serve_one(listener, body);
expect_err(
request_no_redirect(Request::get(url(port, "/")).limits(tight_limits())),
Error::ResponseTooLarge,
);
}
#[test]
fn timed_out_connect_does_not_wedge_later_loopback() {
let mut limits = Limits::default();
limits.total_timeout = Duration::from_millis(150);
let first = request_no_redirect(Request::get("https://172.16.0.4:1/").limits(limits));
assert!(
matches!(first, Err(Error::Timeout) | Err(Error::Connect) | Err(Error::Cancelled)),
"{first:?}"
);
let (listener, port) = listen();
let _ = serve_one(
listener,
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok".to_vec(),
);
let resp = request_no_redirect(Request::get(url(port, "/"))).expect("loopback after timeout");
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"ok");
}