mod common;
use std::time::Duration;
use common::{BodyMode, Request as SReq, Response as SResp, TestServer};
use rsurl::{CancelToken, CookieJar, Error, Request, ResponseHead};
#[test]
fn get_returns_body() {
let server = TestServer::start(|_req: SReq| SResp::ok("hello"));
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.reason, "OK");
assert_eq!(resp.body, b"hello");
}
#[test]
fn head_has_no_body() {
let server = TestServer::start(|_req: SReq| {
SResp {
status: 200,
reason: "OK".into(),
headers: vec![("Content-Length".into(), "5".into())],
body: Vec::new(),
mode: BodyMode::CloseDelimited, }
});
let resp = Request::new("HEAD", &server.url("/"))
.unwrap()
.send()
.unwrap();
assert_eq!(resp.status, 200);
assert!(resp.body.is_empty(), "HEAD body must be empty");
}
#[test]
fn chunked_encoding() {
let server = TestServer::start(|_req: SReq| {
SResp::ok(Vec::new()).mode(BodyMode::Chunked {
chunks: vec![b"abc".to_vec(), b"defg".to_vec(), b"hi".to_vec()],
trailers: vec![],
})
});
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"abcdefghi");
}
#[test]
fn chunked_with_trailers() {
let server = TestServer::start(|_req: SReq| {
SResp::ok(Vec::new()).mode(BodyMode::Chunked {
chunks: vec![b"hello ".to_vec(), b"world".to_vec()],
trailers: vec![("X-Trailer".into(), "ignored".into())],
})
});
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"hello world");
}
#[test]
fn content_length_mismatch_short() {
let server = TestServer::start(|_req: SReq| SResp {
status: 200,
reason: "OK".into(),
headers: vec![],
body: vec![b'a'; 100],
mode: BodyMode::ContentLengthShort {
declared: 100,
actual_len: 50,
},
});
let err = Request::get(&server.url("/")).unwrap().send().unwrap_err();
assert!(
matches!(err, Error::UnexpectedEof),
"expected UnexpectedEof, got {err:?}",
);
}
#[test]
fn close_delimited_body() {
let server = TestServer::start(|_req: SReq| SResp {
status: 200,
reason: "OK".into(),
headers: vec![],
body: b"hello".to_vec(),
mode: BodyMode::CloseDelimited,
});
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"hello");
}
#[test]
fn large_body_1mb() {
let payload: Vec<u8> = {
let mut v = Vec::with_capacity(1 << 20);
let mut state: u32 = 0x1234_5678;
for _ in 0..(1 << 20) {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
v.push((state >> 24) as u8);
}
v
};
let payload_clone = payload.clone();
let server = TestServer::start(move |_req: SReq| SResp::ok(payload_clone.clone()));
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body.len(), payload.len());
assert!(resp.body == payload, "1 MiB body did not round-trip");
}
#[test]
fn status_204_no_body() {
let server = TestServer::start(|_req: SReq| SResp {
status: 204,
reason: "No Content".into(),
headers: vec![],
body: Vec::new(),
mode: BodyMode::CloseDelimited,
});
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(resp.status, 204);
assert_eq!(resp.reason, "No Content");
assert!(resp.body.is_empty());
}
#[test]
fn request_headers_propagate() {
let server = TestServer::start(|req: SReq| {
let mut body = Vec::new();
for (k, v) in &req.headers {
body.extend_from_slice(k.as_bytes());
body.extend_from_slice(b": ");
body.extend_from_slice(v.as_bytes());
body.push(b'\n');
}
SResp::ok(body)
});
let resp = Request::get(&server.url("/probe")).unwrap().send().unwrap();
let text = String::from_utf8(resp.body).expect("ascii reflected headers");
let expected_ua = concat!("User-Agent: rsurl/", env!("CARGO_PKG_VERSION"));
assert!(text.contains(expected_ua), "missing default UA in: {text}");
assert!(text.contains("Accept: */*\n"), "missing Accept in: {text}");
let expected_host = format!("Host: {}\n", server.addr);
assert!(
text.contains(&expected_host),
"missing/wrong Host in: {text}",
);
assert!(
!text.to_ascii_lowercase().contains("connection: close"),
"must not advertise Connection: close (keep-alive is the HTTP/1.1 default): {text}",
);
}
#[test]
fn custom_user_agent_overrides() {
let server = TestServer::start(|req: SReq| {
let ua = req.header("User-Agent").unwrap_or("").to_string();
SResp::ok(ua)
});
let resp = Request::get(&server.url("/"))
.unwrap()
.header("User-Agent", "test/1")
.send()
.unwrap();
assert_eq!(resp.body, b"test/1");
let default_ua = concat!("rsurl/", env!("CARGO_PKG_VERSION"));
assert!(
!resp
.body
.windows(default_ua.len())
.any(|w| w == default_ua.as_bytes()),
"default UA leaked alongside the override",
);
}
#[test]
fn post_with_body_sets_content_length() {
let server = TestServer::start(|req: SReq| {
assert_eq!(req.method, "POST");
assert_eq!(req.header("Content-Length"), Some("5"));
assert_eq!(req.body, b"hello");
SResp::ok("ack")
});
let resp = Request::new("POST", &server.url("/echo"))
.unwrap()
.body("hello".as_bytes().to_vec())
.send()
.unwrap();
assert_eq!(resp.body, b"ack");
}
#[test]
fn verbose_trace_format() {
let server = TestServer::start(|_req: SReq| SResp::ok("hi"));
let mut trace = Vec::new();
let resp = Request::get(&server.url("/"))
.unwrap()
.send_traced(&mut trace)
.unwrap();
assert_eq!(resp.body, b"hi");
let t = String::from_utf8(trace).expect("trace should be utf-8");
assert!(
t.contains("> GET / HTTP/1.1"),
"missing request line in:\n{t}"
);
assert!(
t.contains("< HTTP/1.1 200"),
"missing response status in:\n{t}",
);
assert!(
t.contains("* Connection kept alive (pooled)") || t.contains("* Connection closed"),
"missing connection-state epilogue in:\n{t}",
);
}
#[test]
fn gzip_response_is_decoded() {
let plain = b"hello compressed world".to_vec();
let gz = compcol::vec::compress_to_vec::<compcol::gzip::Gzip>(&plain).unwrap();
let plain_for_server = plain.clone();
let gz_for_server = gz.clone();
let server = TestServer::start(move |req: SReq| {
let ae = req.header("Accept-Encoding").unwrap_or("");
assert!(ae.contains("gzip"), "Accept-Encoding missing gzip: {ae:?}");
let _ = plain_for_server; SResp::ok(gz_for_server.clone()).header("Content-Encoding", "gzip")
});
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, plain, "body should be the decoded plaintext");
assert!(
!resp
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-encoding")),
"Content-Encoding leaked through: {:?}",
resp.headers,
);
assert!(
!resp
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-length")),
"stale Content-Length leaked through: {:?}",
resp.headers,
);
}
#[test]
fn gzip_response_not_decoded_when_decompress_off() {
let plain = b"hello compressed world".to_vec();
let gz = compcol::vec::compress_to_vec::<compcol::gzip::Gzip>(&plain).unwrap();
let gz_for_server = gz.clone();
let server = TestServer::start(move |_req: SReq| {
SResp::ok(gz_for_server.clone()).header("Content-Encoding", "gzip")
});
let resp = Request::get(&server.url("/"))
.unwrap()
.decompress(false)
.send()
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(
resp.body, gz,
"body should be the undecoded gzip wire bytes"
);
assert_eq!(
resp.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-encoding"))
.map(|(_, v)| v.as_str()),
Some("gzip"),
"Content-Encoding must be preserved when decompression is off",
);
}
#[test]
fn send_reader_streams_raw_content_length_body() {
use std::io::Read;
let plain = b"streamed raw body content".to_vec();
let gz = compcol::vec::compress_to_vec::<compcol::gzip::Gzip>(&plain).unwrap();
let gz_for_server = gz.clone();
let server = TestServer::start(move |_req: SReq| {
SResp::ok(gz_for_server.clone()).header("Content-Encoding", "gzip")
});
let mut reader = Request::get(&server.url("/"))
.unwrap()
.send_reader()
.unwrap();
assert_eq!(reader.status(), 200);
assert_eq!(
reader.header("content-encoding"),
Some("gzip"),
"Content-Encoding must be visible and preserved on the streaming reader",
);
let mut got = Vec::new();
reader.read_to_end(&mut got).unwrap();
assert_eq!(got, gz, "streamed bytes must be the raw undecoded body");
}
#[test]
fn send_reader_handles_chunked_body() {
use std::io::Read;
let server = TestServer::start(|_req: SReq| {
SResp::ok(Vec::new())
.body(b"hello world".to_vec())
.mode(BodyMode::Chunked {
chunks: vec![b"hello ".to_vec(), b"world".to_vec()],
trailers: Vec::new(),
})
});
let mut reader = Request::get(&server.url("/"))
.unwrap()
.send_reader()
.unwrap();
assert_eq!(reader.status(), 200);
let mut got = String::new();
reader.read_to_string(&mut got).unwrap();
assert_eq!(got, "hello world");
}
#[test]
fn send_reader_errors_on_truncated_length_body() {
use std::io::Read;
let server = TestServer::start(|_req: SReq| {
SResp::ok(vec![b'x'; 100]).mode(BodyMode::ContentLengthShort {
declared: 100,
actual_len: 10,
})
});
let mut reader = Request::get(&server.url("/"))
.unwrap()
.send_reader()
.unwrap();
let mut got = Vec::new();
let err = reader.read_to_end(&mut got).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::UnexpectedEof);
}
#[test]
fn send_reader_buffers_via_custom_connector() {
use rsurl::net::{Connector, NetStream};
use std::io::Read;
use std::sync::Arc;
use std::time::Duration;
let plain = b"buffered fallback body".to_vec();
let gz = compcol::vec::compress_to_vec::<compcol::gzip::Gzip>(&plain).unwrap();
let gz_for_server = gz.clone();
let server = TestServer::start(move |_req: SReq| {
SResp::ok(gz_for_server.clone()).header("Content-Encoding", "gzip")
});
#[derive(Debug)]
struct DirectDial;
impl Connector for DirectDial {
fn connect(
&self,
host: &str,
port: u16,
_t: Option<Duration>,
) -> rsurl::Result<Box<dyn NetStream>> {
Ok(Box::new(std::net::TcpStream::connect((host, port))?))
}
}
let mut reader = Request::get(&server.url("/"))
.unwrap()
.connector(Arc::new(DirectDial))
.send_reader()
.unwrap();
assert_eq!(reader.status(), 200);
assert_eq!(reader.header("content-encoding"), Some("gzip"));
let mut got = Vec::new();
reader.read_to_end(&mut got).unwrap();
assert_eq!(
got, gz,
"buffered fallback must still hand back raw undecoded bytes",
);
}
#[test]
fn send_reader_follows_redirect() {
use std::io::Read;
let server = TestServer::start(|req: SReq| {
if req.path == "/start" {
SResp::status(302).header("Location", "/dest")
} else {
SResp::ok("final body")
}
});
let mut reader = Request::get(&server.url("/start"))
.unwrap()
.follow_redirects(true)
.send_reader()
.unwrap();
assert_eq!(reader.status(), 200);
let mut got = String::new();
reader.read_to_string(&mut got).unwrap();
assert_eq!(got, "final body");
}
#[test]
fn into_reader_is_read_and_seek() {
use std::io::{Read, Seek, SeekFrom};
let plain = b"abcdefghij".to_vec();
let gz = compcol::vec::compress_to_vec::<compcol::gzip::Gzip>(&plain).unwrap();
let gz_for_server = gz.clone();
let server = TestServer::start(move |_req: SReq| {
SResp::ok(gz_for_server.clone()).header("Content-Encoding", "gzip")
});
let resp = Request::get(&server.url("/"))
.unwrap()
.decompress(false)
.send()
.unwrap();
let mut reader = resp.into_reader();
let mut got = Vec::new();
reader.read_to_end(&mut got).unwrap();
assert_eq!(got, gz, "cursor must yield the raw undecoded bytes");
reader.seek(SeekFrom::Start(0)).unwrap();
let mut first = [0u8; 1];
reader.read_exact(&mut first).unwrap();
assert_eq!(first[0], gz[0]);
}
#[test]
fn deflate_response_is_decoded() {
let plain = b"deflate body".to_vec();
let z = compcol::vec::compress_to_vec::<compcol::zlib::Zlib>(&plain).unwrap();
let z_for_server = z.clone();
let server = TestServer::start(move |_req: SReq| {
SResp::ok(z_for_server.clone()).header("Content-Encoding", "deflate")
});
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(resp.body, plain);
}
#[test]
fn streaming_delivers_head_before_body() {
let server = TestServer::start(|_req: SReq| SResp::ok("hello streaming world"));
#[derive(PartialEq, Debug)]
enum Ev {
Head(u16),
Chunk,
}
let events = std::cell::RefCell::new(Vec::new());
let body = std::cell::RefCell::new(Vec::new());
let resp = Request::get(&server.url("/"))
.unwrap()
.send_streaming(
|h: &ResponseHead| events.borrow_mut().push(Ev::Head(h.status)),
|chunk: &[u8]| {
events.borrow_mut().push(Ev::Chunk);
body.borrow_mut().extend_from_slice(chunk);
Ok(())
},
)
.unwrap();
let events = events.into_inner();
let body = body.into_inner();
assert_eq!(resp.status, 200);
assert!(resp.body.is_empty(), "streamed body must not be buffered");
assert_eq!(body, b"hello streaming world");
assert_eq!(events[0], Ev::Head(200));
assert_eq!(
events.iter().filter(|e| matches!(e, Ev::Head(_))).count(),
1
);
let first_chunk = events.iter().position(|e| *e == Ev::Chunk);
if let Some(idx) = first_chunk {
assert!(idx > 0, "a chunk arrived before the head");
}
}
#[test]
fn streaming_decodes_gzip_incrementally() {
let plain = b"the quick brown fox jumps over the lazy dog".repeat(50);
let gz = compcol::vec::compress_to_vec::<compcol::gzip::Gzip>(&plain).unwrap();
let gz_for_server = gz.clone();
let server = TestServer::start(move |_req: SReq| {
SResp::ok(gz_for_server.clone()).header("Content-Encoding", "gzip")
});
let got_head = std::cell::Cell::new(false);
let head_before_body = std::cell::Cell::new(true);
let body = std::cell::RefCell::new(Vec::new());
Request::get(&server.url("/"))
.unwrap()
.send_streaming(
|_h: &ResponseHead| got_head.set(true),
|chunk: &[u8]| {
if !got_head.get() {
head_before_body.set(false);
}
body.borrow_mut().extend_from_slice(chunk);
Ok(())
},
)
.unwrap();
assert!(got_head.get(), "head callback never fired");
assert!(head_before_body.get(), "body chunk arrived before the head");
assert_eq!(
body.into_inner(),
plain,
"streamed body should be decoded plaintext"
);
}
#[test]
fn streaming_chunk_error_aborts() {
let server = TestServer::start(|_req: SReq| SResp::ok("abcdefgh"));
let err = Request::get(&server.url("/"))
.unwrap()
.send_streaming(
|_h: &ResponseHead| {},
|_chunk: &[u8]| Err(Error::BadResponse("stop".into())),
)
.unwrap_err();
assert!(matches!(err, Error::BadResponse(m) if m == "stop"));
}
#[test]
fn strict_headers_suppress_auto_injection() {
let server = TestServer::start(|req: SReq| {
let mut body = Vec::new();
for (k, v) in &req.headers {
body.extend_from_slice(format!("{k}: {v}\n").as_bytes());
}
SResp::ok(body)
});
let resp = Request::get(&server.url("/"))
.unwrap()
.strict_headers(true)
.header("X-Custom", "yes")
.send()
.unwrap();
let text = String::from_utf8(resp.body).unwrap().to_ascii_lowercase();
assert!(
text.contains("x-custom: yes"),
"custom header missing: {text}"
);
assert!(text.contains("host: "), "Host must still be sent: {text}");
assert!(
!text.contains("user-agent:"),
"UA should be suppressed: {text}"
);
assert!(
!text.contains("accept:"),
"Accept should be suppressed: {text}"
);
assert!(
!text.contains("accept-encoding:"),
"Accept-Encoding should be suppressed: {text}"
);
}
#[test]
fn keep_method_case_controls_wire_method() {
let server = TestServer::start(|req: SReq| SResp::ok(req.method.clone()));
let upper = Request::new("query", &server.url("/"))
.unwrap()
.send()
.unwrap();
assert_eq!(upper.body, b"QUERY", "default should upper-case the method");
let server2 = TestServer::start(|req: SReq| SResp::ok(req.method.clone()));
let exact = Request::new("query", &server2.url("/"))
.unwrap()
.keep_method_case(true)
.send()
.unwrap();
assert_eq!(
exact.body, b"query",
"keep_method_case should preserve case"
);
}
#[test]
fn cancel_token_aborts_streaming_download() {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
let _ =
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\n\r\nstart-of-body");
let _ = sock.flush();
std::thread::sleep(Duration::from_secs(5));
drop(sock);
}
});
let token = CancelToken::new();
let canceller = token.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
canceller.cancel();
});
let url = format!("http://{addr}/");
let err = Request::get(&url)
.unwrap()
.cancel_token(token)
.send_streaming(|_h: &ResponseHead| {}, |_chunk: &[u8]| Ok(()))
.unwrap_err();
assert!(
matches!(err, Error::Cancelled),
"expected Error::Cancelled, got {err:?}"
);
let _ = server.join();
}
#[test]
fn connect_refused_is_io_error() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind to grab a free port");
let addr = listener.local_addr().unwrap();
drop(listener);
let url = format!("http://{addr}/");
let err = Request::get(&url)
.unwrap()
.connect_timeout(Duration::from_secs(2))
.send()
.unwrap_err();
assert!(
matches!(err, Error::Io(_)),
"expected Error::Io, got {err:?}",
);
}
#[test]
fn set_cookie_lands_in_jar() {
let server =
TestServer::start(|_req: SReq| SResp::ok("ok").header("Set-Cookie", "sid=abc; Path=/"));
let mut jar = CookieJar::new();
let resp = Request::get(&server.url("/"))
.unwrap()
.send_with_jar(&mut jar)
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(jar.len(), 1, "expected one cookie, jar={jar:?}");
let url = rsurl::Url::parse(&server.url("/")).unwrap();
assert_eq!(jar.cookie_header(&url).as_deref(), Some("sid=abc"));
}
#[test]
fn cookie_traverses_redirect_chain() {
use std::sync::{Arc, Mutex};
let observed: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let obs_for_handler = Arc::clone(&observed);
let server = TestServer::start(move |req: SReq| {
if req.path == "/start" {
SResp::status(302)
.header("Set-Cookie", "sid=abc; Path=/")
.header("Location", "/home")
} else if req.path == "/home" {
let got = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("cookie"))
.map(|(_, v)| v.clone());
*obs_for_handler.lock().unwrap() = got;
SResp::ok("welcome")
} else {
SResp::status(404)
}
});
let mut jar = CookieJar::new();
let resp = Request::get(&server.url("/start"))
.unwrap()
.follow_redirects(true)
.send_with_jar(&mut jar)
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"welcome");
let cookie_seen = observed.lock().unwrap().clone();
assert_eq!(
cookie_seen.as_deref(),
Some("sid=abc"),
"expected sid=abc on the redirected hop, got {cookie_seen:?}",
);
}
#[test]
fn cookies_not_carried_across_redirects_when_disabled() {
use std::sync::{Arc, Mutex};
let observed: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let obs_for_handler = Arc::clone(&observed);
let server = TestServer::start(move |req: SReq| {
if req.path == "/start" {
SResp::status(302)
.header("Set-Cookie", "sid=abc; Path=/")
.header("Location", "/home")
} else {
*obs_for_handler.lock().unwrap() = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("cookie"))
.map(|(_, v)| v.clone());
SResp::ok("welcome")
}
});
let mut jar = CookieJar::new();
let resp = Request::get(&server.url("/start"))
.unwrap()
.follow_redirects(true)
.cookies_through_redirects(false)
.send_with_jar(&mut jar)
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(
observed.lock().unwrap().clone(),
None,
"no cookie should be sent on the redirected hop when disabled"
);
}
#[test]
fn timing_total_and_namelookup_populated() {
let server = TestServer::start(|_req: SReq| SResp::ok("ok"));
let resp = Request::get(&server.url("/")).unwrap().send().unwrap();
assert!(resp.timing.total.is_some(), "total time should be set");
assert!(
resp.timing.namelookup.is_some(),
"DNS namelookup time should be set on a fresh dial"
);
}
#[test]
fn custom_resolver_reaches_server() {
let server = TestServer::start(|_req: SReq| SResp::ok("via-resolver"));
let server_addr = server.addr;
#[derive(Debug)]
struct PinResolver(std::net::SocketAddr);
impl rsurl::net::Resolver for PinResolver {
fn resolve(&self, _host: &str, _port: u16) -> rsurl::Result<Vec<std::net::SocketAddr>> {
Ok(vec![self.0])
}
}
let url = format!("http://made-up-host.invalid:{}/", server_addr.port());
let resp = Request::get(&url)
.unwrap()
.resolver(std::sync::Arc::new(PinResolver(server_addr)))
.send()
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"via-resolver");
}
#[test]
fn partition_key_isolates_pool() {
use std::sync::atomic::Ordering;
let server = TestServer::start_keepalive(|_req: SReq| SResp::ok("ok"));
let r1 = Request::get(&server.url("/a"))
.unwrap()
.partition("siteA")
.send()
.unwrap();
assert_eq!(r1.status, 200);
std::thread::sleep(Duration::from_millis(30));
let r2 = Request::get(&server.url("/b"))
.unwrap()
.partition("siteB")
.send()
.unwrap();
assert_eq!(r2.status, 200);
let accepted = server.accept_count.load(Ordering::SeqCst);
assert_eq!(
accepted, 2,
"different partition keys must not reuse the pooled connection, got {accepted}",
);
}
#[test]
fn proxy_resolver_direct_connects_normally() {
let server = TestServer::start(|_req: SReq| SResp::ok("direct"));
#[derive(Debug)]
struct AlwaysDirect;
impl rsurl::net::ProxyResolver for AlwaysDirect {
fn resolve(&self, _url: &rsurl::Url) -> rsurl::net::ProxyChoice {
rsurl::net::ProxyChoice::Direct
}
}
let resp = Request::get(&server.url("/"))
.unwrap()
.proxy_resolver(std::sync::Arc::new(AlwaysDirect))
.send()
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"direct");
}
#[test]
fn proxy_resolver_routes_via_proxy() {
use std::sync::{Arc, Mutex};
let seen_line: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let seen_for_handler = Arc::clone(&seen_line);
let proxy = TestServer::start(move |req: SReq| {
*seen_for_handler.lock().unwrap() = Some(req.path.clone());
SResp::ok("via-proxy")
});
let proxy_addr = proxy.addr;
#[derive(Debug)]
struct ToProxy(String);
impl rsurl::net::ProxyResolver for ToProxy {
fn resolve(&self, _url: &rsurl::Url) -> rsurl::net::ProxyChoice {
rsurl::net::ProxyChoice::Proxy(self.0.clone())
}
}
let resp = Request::get("http://example.com/page")
.unwrap()
.proxy_resolver(Arc::new(ToProxy(format!("http://{proxy_addr}"))))
.send()
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"via-proxy");
let line = seen_line.lock().unwrap().clone().unwrap_or_default();
assert!(
line.contains("http://example.com/page"),
"proxy should receive absolute-form target, got {line:?}"
);
}
#[test]
fn final_url_reflects_redirect_target() {
let server = TestServer::start(move |req: SReq| {
if req.path == "/start" {
SResp::status(302).header("Location", "/dest")
} else {
SResp::ok("here")
}
});
let resp = Request::get(&server.url("/start"))
.unwrap()
.follow_redirects(true)
.send()
.unwrap();
assert_eq!(resp.body, b"here");
assert!(
resp.final_url.ends_with("/dest"),
"final_url should be the redirect target, got {:?}",
resp.final_url
);
let direct = Request::get(&server.url("/dest")).unwrap().send().unwrap();
assert!(
direct.final_url.ends_with("/dest"),
"{:?}",
direct.final_url
);
}
#[test]
fn jar_is_empty_when_server_sets_no_cookie() {
use std::sync::{Arc, Mutex};
let observed: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let obs = Arc::clone(&observed);
let server = TestServer::start(move |req: SReq| {
let got = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("cookie"))
.map(|(_, v)| v.clone());
*obs.lock().unwrap() = got;
SResp::ok("ok")
});
let mut jar = CookieJar::new();
let resp = Request::get(&server.url("/"))
.unwrap()
.send_with_jar(&mut jar)
.unwrap();
assert_eq!(resp.status, 200);
assert!(jar.is_empty(), "jar should be empty");
assert!(
observed.lock().unwrap().is_none(),
"should not have sent any Cookie: header"
);
}
#[test]
fn plain_http_via_proxy_uses_absolute_form() {
let proxy = TestServer::start(|req: SReq| {
let host = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("host"))
.map(|(_, v)| v.clone())
.unwrap_or_default();
let body = format!("{} {}\nHost: {host}\n", req.method, req.path);
SResp::ok(body)
});
let origin = "http://origin.invalid/some/path?q=1";
let resp = Request::get(origin)
.unwrap()
.proxy(proxy.url("").trim_end_matches('/'))
.unwrap()
.send()
.unwrap();
let text = String::from_utf8(resp.body).unwrap();
assert!(
text.contains("GET http://origin.invalid/some/path?q=1\n"),
"absolute-form request line missing: {text}",
);
assert!(
text.contains("Host: origin.invalid\n"),
"Host should be the origin's authority, not the proxy's: {text}",
);
}
#[test]
fn plain_http_via_proxy_with_creds() {
use std::sync::{Arc, Mutex};
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let cap2 = Arc::clone(&captured);
let proxy = TestServer::start(move |req: SReq| {
let pa = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("proxy-authorization"))
.map(|(_, v)| v.clone());
*cap2.lock().unwrap() = pa;
SResp::ok("ok")
});
let proxy_url = format!("http://alice:hunter2@{}", proxy.addr);
Request::get("http://origin.invalid/")
.unwrap()
.proxy(&proxy_url)
.unwrap()
.send()
.unwrap();
let got = captured.lock().unwrap().clone();
assert_eq!(got.as_deref(), Some("Basic YWxpY2U6aHVudGVyMg=="));
}
#[test]
fn noproxy_bypasses_proxy() {
let origin = TestServer::start(|_req| SResp::ok("direct"));
let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let closed = l.local_addr().unwrap();
drop(l);
let resp = Request::get(&origin.url("/"))
.unwrap()
.proxy(&format!("http://{closed}"))
.unwrap()
.no_proxy(["127.0.0.1"])
.connect_timeout(Duration::from_secs(2))
.send()
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"direct");
}
#[test]
fn pool_reuses_plain_http_connection() {
use std::sync::atomic::Ordering;
let server = TestServer::start_keepalive(|_req: SReq| SResp::ok("ok"));
let r1 = Request::get(&server.url("/first")).unwrap().send().unwrap();
assert_eq!(r1.status, 200);
std::thread::sleep(Duration::from_millis(30));
let r2 = Request::get(&server.url("/second"))
.unwrap()
.send()
.unwrap();
assert_eq!(r2.status, 200);
let accepted = server.accept_count.load(Ordering::SeqCst);
assert_eq!(
accepted, 1,
"second request should have reused the pooled connection, got {accepted} accepts",
);
}
#[test]
fn pool_skips_when_response_says_close() {
use std::sync::atomic::Ordering;
let server =
TestServer::start_keepalive(|_req: SReq| SResp::ok("ok").header("Connection", "close"));
let _ = Request::get(&server.url("/a")).unwrap().send().unwrap();
std::thread::sleep(Duration::from_millis(30));
let _ = Request::get(&server.url("/b")).unwrap().send().unwrap();
let accepted = server.accept_count.load(Ordering::SeqCst);
assert_eq!(
accepted, 2,
"Connection: close should disable reuse, got {accepted} accepts",
);
}
#[test]
fn pool_skips_close_delimited_response() {
use std::sync::atomic::Ordering;
let server = TestServer::start_keepalive(|_req: SReq| {
SResp::ok("body-bytes").mode(BodyMode::CloseDelimited)
});
let _ = Request::get(&server.url("/a")).unwrap().send().unwrap();
std::thread::sleep(Duration::from_millis(30));
let _ = Request::get(&server.url("/b")).unwrap().send().unwrap();
assert_eq!(server.accept_count.load(Ordering::SeqCst), 2);
}
#[test]
fn pool_retries_when_pooled_connection_is_stale() {
use std::sync::atomic::Ordering;
let server = TestServer::start(|_req: SReq| SResp::ok("once"));
let r1 = Request::get(&server.url("/")).unwrap().send().unwrap();
assert_eq!(r1.body, b"once");
std::thread::sleep(Duration::from_millis(30));
let r2 = Request::get(&server.url("/"))
.unwrap()
.connect_timeout(Duration::from_secs(2))
.send()
.unwrap();
assert_eq!(r2.body, b"once");
assert_eq!(server.accept_count.load(Ordering::SeqCst), 2);
}
fn tmp_out_path(tag: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("rsurl-{tag}-{}.out", std::process::id()));
p
}
type CapturedRequest = (String, Option<String>, Vec<u8>);
type CapturedSlot = std::sync::Arc<std::sync::Mutex<Option<CapturedRequest>>>;
fn capture_one_request() -> (TestServer, CapturedSlot) {
use std::sync::{Arc, Mutex};
let slot: CapturedSlot = Arc::new(Mutex::new(None));
let slot2 = Arc::clone(&slot);
let server = TestServer::start(move |req: SReq| {
let ct = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.clone());
*slot2.lock().unwrap() = Some((req.method.clone(), ct, req.body.clone()));
SResp::ok("ok")
});
(server, slot)
}
#[test]
fn cli_data_binary_at_file_keeps_newlines() {
use std::io::Write;
use std::process::Command;
let (server, slot) = capture_one_request();
let mut tmp = std::env::temp_dir();
tmp.push(format!("rsurl-data-binary-{}.bin", std::process::id()));
{
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"a\r\nb\n").unwrap();
}
let arg = format!("@{}", tmp.display());
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["--data-binary", &arg, &server.url("/post")])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
assert_eq!(got.0, "POST");
assert_eq!(
got.1.as_deref(),
Some("application/x-www-form-urlencoded"),
"default Content-Type for --data-binary should match -d"
);
assert_eq!(got.2, b"a\r\nb\n", "CRLF/LF must survive --data-binary");
}
#[test]
fn cli_data_urlencode_all_five_forms() {
use std::io::Write;
use std::process::Command;
let (server, slot) = capture_one_request();
let mut tmp = std::env::temp_dir();
tmp.push(format!("rsurl-urlencode-{}.txt", std::process::id()));
{
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"x y&z").unwrap();
}
let at = format!("@{}", tmp.display());
let name_at = format!("g@{}", tmp.display());
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"--data-urlencode",
"hello world",
"--data-urlencode",
"=raw value",
"--data-urlencode",
"k=v with space",
"--data-urlencode",
&at,
"--data-urlencode",
&name_at,
&server.url("/post"),
])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
assert_eq!(got.0, "POST");
let body = String::from_utf8(got.2).expect("ascii body");
assert_eq!(
body, "hello+world&raw+value&k=v+with+space&x+y%26z&g=x+y%26z",
"every --data-urlencode sub-form must match curl's encoding"
);
}
#[test]
fn cli_multiple_d_join_with_ampersand() {
use std::process::Command;
let (server, slot) = capture_one_request();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-d", "a=1", "-d", "b=2", "-d", "c=3", &server.url("/post")])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
assert_eq!(got.0, "POST");
assert_eq!(got.2, b"a=1&b=2&c=3");
}
fn extract_boundary(ct: &str) -> String {
let lc = ct.to_ascii_lowercase();
let prefix = "boundary=";
let i = lc.find(prefix).expect("boundary= in Content-Type");
let mut rest = &ct[i + prefix.len()..];
if let Some(stripped) = rest.strip_prefix('"') {
rest = stripped;
let end = rest.find('"').expect("closing quote on boundary");
rest[..end].to_string()
} else {
let end = rest.find(';').unwrap_or(rest.len());
rest[..end].trim().to_string()
}
}
fn find_part<'a>(body: &'a [u8], boundary: &str, needle: &str) -> &'a [u8] {
let sep = format!("--{boundary}\r\n");
let term = format!("--{boundary}--");
let body_str =
std::str::from_utf8(body).expect("multipart body should be UTF-8 in these tests");
let mut chunks = body_str.split(&sep);
let _ = chunks.next(); for chunk in chunks {
if chunk.contains(needle) {
let end = chunk.find(&term).unwrap_or(chunk.len());
let mut end_no_crlf = end;
if end_no_crlf >= 2 && &chunk[end_no_crlf - 2..end_no_crlf] == "\r\n" {
end_no_crlf -= 2;
}
return &chunk.as_bytes()[..end_no_crlf];
}
}
panic!("no part matched: {needle:?}");
}
#[test]
fn cli_form_part_with_at_file_uploads_bytes() {
use std::io::Write;
use std::process::Command;
let (server, slot) = capture_one_request();
let mut tmp = std::env::temp_dir();
tmp.push(format!("rsurl-form-{}.bin", std::process::id()));
{
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"PAYLOAD-BYTES").unwrap();
}
let basename = tmp.file_name().unwrap().to_string_lossy().into_owned();
let arg = format!("upload=@{}", tmp.display());
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-F", &arg, &server.url("/post")])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
assert_eq!(got.0, "POST");
let ct = got.1.expect("Content-Type set");
assert!(
ct.starts_with("multipart/form-data; boundary="),
"got Content-Type: {ct}"
);
let boundary = extract_boundary(&ct);
let part = find_part(&got.2, &boundary, "name=\"upload\"");
let part_str = std::str::from_utf8(part).expect("ascii part headers");
let expected_disposition =
format!("Content-Disposition: form-data; name=\"upload\"; filename=\"{basename}\"\r\n");
assert!(
part_str.contains(&expected_disposition),
"missing disposition in part: {part_str}"
);
assert!(
part_str.contains("Content-Type: application/octet-stream\r\n"),
"missing default Content-Type in part: {part_str}"
);
assert!(
part.ends_with(b"\r\n\r\nPAYLOAD-BYTES"),
"part body should end with the uploaded bytes; got: {part_str}"
);
}
#[test]
fn cli_form_string_treats_at_as_literal() {
use std::process::Command;
let (server, slot) = capture_one_request();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"--form-string",
"field=@notafile;type=ignored",
&server.url("/post"),
])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
let ct = got.1.expect("Content-Type set");
let boundary = extract_boundary(&ct);
let part = find_part(&got.2, &boundary, "name=\"field\"");
let s = std::str::from_utf8(part).unwrap();
assert!(
!s.contains("filename="),
"literal form-string must not become an upload: {s}"
);
assert!(!s.contains("Content-Type:"), "no auto Content-Type: {s}");
assert!(
s.ends_with("\r\n\r\n@notafile;type=ignored"),
"literal value must appear verbatim: {s}"
);
}
#[test]
fn cli_form_extras_type_filename_headers() {
use std::io::Write;
use std::process::Command;
let (server, slot) = capture_one_request();
let mut payload = std::env::temp_dir();
payload.push(format!("rsurl-form-payload-{}.txt", std::process::id()));
std::fs::write(&payload, b"DATA").unwrap();
let mut hdrs = std::env::temp_dir();
hdrs.push(format!("rsurl-form-hdrs-{}.txt", std::process::id()));
{
let mut f = std::fs::File::create(&hdrs).unwrap();
f.write_all(b"X-Custom: yes\r\nX-Other: 42\r\n").unwrap();
}
let arg = format!(
"f=@{};type=application/json;filename=other.json;headers=@{}",
payload.display(),
hdrs.display()
);
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-F", &arg, &server.url("/post")])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&payload);
let _ = std::fs::remove_file(&hdrs);
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
let ct = got.1.expect("Content-Type set");
let boundary = extract_boundary(&ct);
let part = find_part(&got.2, &boundary, "name=\"f\"");
let s = std::str::from_utf8(part).unwrap();
assert!(
s.contains("name=\"f\"; filename=\"other.json\"\r\n"),
"filename override missing: {s}"
);
assert!(
s.contains("Content-Type: application/json\r\n"),
"type modifier missing: {s}"
);
assert!(
s.contains("X-Custom: yes\r\n"),
"header injection missing: {s}"
);
assert!(
s.contains("X-Other: 42\r\n"),
"header injection missing: {s}"
);
assert!(s.ends_with("\r\n\r\nDATA"), "body bytes wrong: {s}");
}
#[test]
fn cli_upload_file_uses_put_and_octet_stream() {
use std::io::Write;
use std::process::Command;
let (server, slot) = capture_one_request();
let mut tmp = std::env::temp_dir();
tmp.push(format!("rsurl-upload-{}.bin", std::process::id()));
{
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"AAA\r\nBBB\n\0CCC").unwrap();
}
let path = tmp.to_string_lossy().into_owned();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-T", &path, &server.url("/put")])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
assert_eq!(got.0, "PUT");
assert_eq!(got.1.as_deref(), Some("application/octet-stream"));
assert_eq!(got.2, b"AAA\r\nBBB\n\0CCC");
}
#[test]
fn cli_upload_file_rejects_unsupported_scheme() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-T",
concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
"dict://example.invalid/foo",
])
.output()
.expect("spawn rsurl");
let code = out.status.code();
assert_eq!(code, Some(2), "expected exit code 2, got {code:?}");
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(err.contains("-T"), "stderr should mention -T: {err}");
}
#[test]
fn cli_upload_file_ftp_attempts_transfer() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-T",
concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
"ftp://host.invalid/foo",
])
.output()
.expect("spawn rsurl");
let code = out.status.code();
assert!(
matches!(code, Some(6) | Some(7)),
"expected a transfer error (6/7), got {code:?}"
);
}
#[test]
fn cli_append_ftp_attempts_transfer() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-a",
"-T",
concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
"ftp://host.invalid/foo",
])
.output()
.expect("spawn rsurl");
let code = out.status.code();
assert!(
matches!(code, Some(6) | Some(7)),
"expected a transfer error (6/7), got {code:?}"
);
}
#[test]
fn cli_append_with_continue_at_prefers_appe() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-a",
"-C",
"10",
"-T",
concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
"ftp://host.invalid/foo",
])
.output()
.expect("spawn rsurl");
let code = out.status.code();
assert!(
matches!(code, Some(6) | Some(7)),
"expected a transfer error (6/7, APPE ignores -C), got {code:?}"
);
}
#[test]
fn cli_continue_at_dash_is_accepted() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-C",
"-",
"-T",
concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
"ftp://host.invalid/foo",
])
.output()
.expect("spawn rsurl");
let code = out.status.code();
assert!(
matches!(code, Some(6) | Some(7)),
"expected a transfer error (6/7), not a usage error, got {code:?}"
);
}
#[test]
fn cli_form_and_data_are_mutually_exclusive() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-d", "a=1", "-F", "b=2", "http://127.0.0.1:1/post"])
.output()
.expect("spawn rsurl");
assert_eq!(out.status.code(), Some(2), "expected exit 2");
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
err.contains("mutually exclusive"),
"stderr should explain conflict: {err}"
);
}
#[test]
fn cli_data_and_upload_are_mutually_exclusive() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-d",
"a=1",
"-T",
concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
"http://127.0.0.1:1/x",
])
.output()
.expect("spawn rsurl");
assert_eq!(out.status.code(), Some(2), "expected exit 2");
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
err.contains("mutually exclusive"),
"stderr should explain conflict: {err}"
);
}
#[test]
fn cli_form_and_upload_are_mutually_exclusive() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-F",
"x=y",
"-T",
concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml"),
"http://127.0.0.1:1/x",
])
.output()
.expect("spawn rsurl");
assert_eq!(out.status.code(), Some(2), "expected exit 2");
let err = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
err.contains("mutually exclusive"),
"stderr should explain conflict: {err}"
);
}
#[test]
fn cli_form_field_from_file_has_no_filename() {
use std::io::Write;
use std::process::Command;
let (server, slot) = capture_one_request();
let mut tmp = std::env::temp_dir();
tmp.push(format!("rsurl-lt-{}.txt", std::process::id()));
{
let mut f = std::fs::File::create(&tmp).unwrap();
f.write_all(b"FIELD-VALUE").unwrap();
}
let arg = format!("note=<{}", tmp.display());
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-F", &arg, &server.url("/post")])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&tmp);
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
let ct = got.1.expect("Content-Type set");
let boundary = extract_boundary(&ct);
let part = find_part(&got.2, &boundary, "name=\"note\"");
let s = std::str::from_utf8(part).unwrap();
assert!(
!s.contains("filename="),
"FileAsField must not add filename=: {s}"
);
assert!(
!s.contains("Content-Type:"),
"FileAsField must not auto-set Content-Type: {s}"
);
assert!(
s.ends_with("\r\n\r\nFIELD-VALUE"),
"file bytes must arrive verbatim as field value: {s}"
);
}
#[test]
fn cli_form_escape_percent_encodes_name() {
use std::process::Command;
let (server, slot) = capture_one_request();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["--form-escape", "-F", "weird\"name=v", &server.url("/post")])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
let ct = got.1.expect("Content-Type set");
let boundary = extract_boundary(&ct);
let part = find_part(&got.2, &boundary, "name=\"weird%22name\"");
let s = std::str::from_utf8(part).unwrap();
assert!(
s.contains("name=\"weird%22name\""),
"expected RFC 7578 %22 encoding, got: {s}"
);
assert!(
!s.contains("\\\""),
"must not also backslash-escape when --form-escape is on: {s}"
);
}
#[test]
fn cli_form_literal_with_filename_modifier_becomes_upload() {
use std::process::Command;
let (server, slot) = capture_one_request();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-F", "blob=hello;filename=hi.txt", &server.url("/post")])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
let ct = got.1.expect("Content-Type set");
let boundary = extract_boundary(&ct);
let part = find_part(&got.2, &boundary, "name=\"blob\"");
let s = std::str::from_utf8(part).unwrap();
assert!(
s.contains("name=\"blob\"; filename=\"hi.txt\"\r\n"),
";filename= must promote literal to upload shape: {s}"
);
assert!(
s.contains("Content-Type: application/octet-stream\r\n"),
"promoted literal needs default octet-stream Content-Type: {s}"
);
assert!(
s.ends_with("\r\n\r\nhello"),
"promoted literal body must be the literal text: {s}"
);
}
#[test]
fn cli_data_raw_leaves_at_literal_on_wire() {
use std::process::Command;
let (server, slot) = capture_one_request();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["--data-raw", "@notafile", &server.url("/post")])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
assert_eq!(got.0, "POST");
assert_eq!(
got.1.as_deref(),
Some("application/x-www-form-urlencoded"),
"--data-raw still defaults to form-urlencoded"
);
assert_eq!(
got.2, b"@notafile",
"--data-raw must put the literal `@` text on the wire"
);
}
#[test]
fn cli_custom_content_type_header_overrides_default() {
use std::process::Command;
let (server, slot) = capture_one_request();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-H",
"Content-Type: application/json",
"-d",
r#"{"k":"v"}"#,
&server.url("/post"),
])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"rsurl exited non-zero: {:?}\nstderr: {}",
out.status,
String::from_utf8_lossy(&out.stderr)
);
let got = slot.lock().unwrap().clone().expect("handler ran");
assert_eq!(got.0, "POST");
assert_eq!(
got.1.as_deref(),
Some("application/json"),
"explicit -H Content-Type must win over the per-flag default"
);
assert_eq!(got.2, br#"{"k":"v"}"#);
}
#[test]
fn cli_http3_only_unresolvable_fails_cleanly() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["--http3-only", "https://host.invalid/"])
.output()
.expect("spawn rsurl");
assert!(
!out.status.success(),
"unresolvable host must fail, got {:?}",
out.status
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(!err.contains("panicked"), "must not panic; stderr: {err}");
}
#[test]
fn cli_http3_only_rejects_plaintext_http() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["--http3-only", "http://host.invalid/"])
.output()
.expect("spawn rsurl");
assert!(
!out.status.success(),
"http:// + --http3-only must fail, got {:?}",
out.status
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(!err.contains("panicked"), "must not panic; stderr: {err}");
}
#[test]
fn cli_http3_with_fallback_unresolvable_fails_cleanly() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["--http3", "https://host.invalid/"])
.output()
.expect("spawn rsurl");
assert!(
!out.status.success(),
"unresolvable host must fail, got {:?}",
out.status
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(!err.contains("panicked"), "must not panic; stderr: {err}");
}
#[test]
fn custom_connector_overrides_transport() {
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
#[derive(Debug)]
struct FixedConnector {
addr: SocketAddr,
}
impl rsurl::net::Connector for FixedConnector {
fn connect(
&self,
_host: &str,
_port: u16,
_timeout: Option<Duration>,
) -> rsurl::Result<Box<dyn rsurl::net::NetStream>> {
Ok(Box::new(TcpStream::connect(self.addr)?))
}
}
let server = TestServer::start(|_req: SReq| SResp::ok("via-connector"));
let resp = Request::get("http://origin.invalid/")
.unwrap()
.connector(Arc::new(FixedConnector { addr: server.addr }))
.send()
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"via-connector");
}
#[test]
fn http_via_socks5h_proxy() {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind socks mock");
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut head = [0u8; 2];
s.read_exact(&mut head).unwrap();
let mut methods = vec![0u8; head[1] as usize];
s.read_exact(&mut methods).unwrap();
s.write_all(&[0x05, 0x00]).unwrap(); let mut req = [0u8; 4];
s.read_exact(&mut req).unwrap();
assert_eq!(req[3], 0x03, "socks5h should send a domain ATYP");
let mut dlen = [0u8; 1];
s.read_exact(&mut dlen).unwrap();
let mut domain = vec![0u8; dlen[0] as usize];
s.read_exact(&mut domain).unwrap();
assert_eq!(&domain, b"origin.invalid");
let mut port = [0u8; 2];
s.read_exact(&mut port).unwrap();
s.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.unwrap();
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
if s.read(&mut byte).unwrap() == 0 {
break;
}
buf.push(byte[0]);
if buf.ends_with(b"\r\n\r\n") {
break;
}
}
s.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
.unwrap();
s.flush().unwrap();
});
let resp = Request::get("http://origin.invalid/")
.unwrap()
.proxy(&format!("socks5h://{addr}"))
.unwrap()
.connect_timeout(Duration::from_secs(2))
.send()
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, b"hello");
handle.join().unwrap();
}
#[test]
fn client_custom_connector_drives_gopher() {
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind gopher mock");
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut buf = Vec::new();
let mut byte = [0u8; 1];
loop {
if s.read(&mut byte).unwrap() == 0 {
break;
}
buf.push(byte[0]);
if buf.ends_with(b"\r\n") {
break;
}
}
s.write_all(b"1Welcome\tfake\texample.com\t70\r\n.\r\n")
.unwrap();
});
#[derive(Debug)]
struct Fixed {
addr: SocketAddr,
}
impl rsurl::net::Connector for Fixed {
fn connect(
&self,
_host: &str,
_port: u16,
_t: Option<Duration>,
) -> rsurl::Result<Box<dyn rsurl::net::NetStream>> {
Ok(Box::new(TcpStream::connect(self.addr)?))
}
}
let body = rsurl::Client::new()
.connector(Arc::new(Fixed { addr }))
.transfer("gopher://origin.invalid/1sel")
.unwrap();
assert!(body.starts_with(b"1Welcome"));
handle.join().unwrap();
}
#[test]
fn cli_fail_flag_controls_exit_and_body() {
use std::process::Command;
let server = TestServer::start(|_req: SReq| SResp::status(404).body("nope"));
let url = server.url("/missing");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-f", "-s", &url])
.output()
.expect("spawn rsurl");
assert_eq!(out.status.code(), Some(22), "-f on 404 should exit 22");
assert!(out.stdout.is_empty(), "-f must suppress the body");
let out2 = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", &url])
.output()
.expect("spawn rsurl");
assert_eq!(out2.status.code(), Some(0), "no -f on 404 should exit 0");
assert_eq!(out2.stdout, b"nope");
}
#[test]
fn cli_get_moves_data_to_query() {
use std::process::Command;
let server = TestServer::start(|req: SReq| SResp::ok(format!("{} {}", req.method, req.path)));
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "-G", "-d", "a=1", "-d", "b=2", &server.url("/q")])
.output()
.expect("spawn rsurl");
let body = String::from_utf8_lossy(&out.stdout);
assert_eq!(body, "GET /q?a=1&b=2", "got: {body}");
}
#[test]
fn cli_write_out_expands_vars() {
use std::process::Command;
let server = TestServer::start(|_req: SReq| SResp::ok("hello"));
let out_path = tmp_out_path("wo-vars");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-o",
out_path.to_str().unwrap(),
"-w",
"%{http_code} %{size_download}",
&server.url("/"),
])
.output()
.expect("spawn rsurl");
assert_eq!(String::from_utf8_lossy(&out.stdout), "200 5");
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn cli_netrc_supplies_basic_auth() {
use std::io::Write;
use std::process::Command;
use std::sync::{Arc, Mutex};
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let cap2 = Arc::clone(&captured);
let server = TestServer::start(move |req: SReq| {
*cap2.lock().unwrap() = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
.map(|(_, v)| v.clone());
SResp::ok("ok")
});
let mut netrc = std::env::temp_dir();
netrc.push(format!("rsurl-netrc-{}", std::process::id()));
{
let mut f = std::fs::File::create(&netrc).unwrap();
writeln!(f, "machine 127.0.0.1 login alice password s3cret").unwrap();
}
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--netrc-file",
netrc.to_str().unwrap(),
&server.url("/"),
])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&netrc);
assert!(out.status.success());
assert_eq!(
captured.lock().unwrap().as_deref(),
Some("Basic YWxpY2U6czNjcmV0")
);
}
#[test]
fn cli_remote_header_name_uses_content_disposition() {
use std::process::Command;
let server = TestServer::start(|_req: SReq| {
let mut r = SResp::ok("payload");
r.headers.push((
"Content-Disposition".into(),
"attachment; filename=\"/etc/cd-name.txt\"".into(),
));
r
});
let dir = std::env::temp_dir().join(format!("rsurl-cd-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.current_dir(&dir)
.args(["-s", "-O", "-J", &server.url("/file")])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let saved = std::fs::read(dir.join("cd-name.txt")).expect("file named from CD basename");
assert_eq!(saved, b"payload");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cli_resolve_overrides_dns() {
use std::process::Command;
let server = TestServer::start(|_req: SReq| SResp::ok("resolved"));
let port = server.addr.port();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--resolve",
&format!("origin.invalid:{port}:127.0.0.1"),
&format!("http://origin.invalid:{port}/"),
])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"resolved");
}
#[test]
fn cli_next_runs_multiple_operations() {
use std::process::Command;
let a = TestServer::start(|_r: SReq| SResp::ok("AAA"));
let b = TestServer::start(|_r: SReq| SResp::ok("BBB"));
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", &a.url("/"), "--next", "-s", &b.url("/")])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
assert_eq!(out.stdout, b"AAABBB");
}
#[test]
fn cli_config_file_supplies_options() {
use std::io::Write;
use std::process::Command;
let server = TestServer::start(|_r: SReq| SResp::ok("from-config"));
let mut cfg = std::env::temp_dir();
cfg.push(format!("rsurl-cfg-{}", std::process::id()));
{
let mut f = std::fs::File::create(&cfg).unwrap();
writeln!(f, "# a comment").unwrap();
writeln!(f, "silent").unwrap();
writeln!(f, "url = \"{}\"", server.url("/")).unwrap();
}
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-K", cfg.to_str().unwrap()])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&cfg);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"from-config");
}
#[test]
fn cli_bundled_short_flags() {
use std::process::Command;
let server = TestServer::start(|_r: SReq| SResp::ok("bundled"));
let dir = std::env::temp_dir().join(format!("rsurl-bundle-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let outfile = dir.join("o.txt");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.arg(format!("-sSo{}", outfile.display()))
.arg(server.url("/"))
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(out.stdout.is_empty(), "-s should silence stdout");
assert_eq!(std::fs::read(&outfile).unwrap(), b"bundled");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cli_fail_with_body() {
use std::process::Command;
let server = TestServer::start(|_r: SReq| SResp::status(404).body("err-body"));
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "--fail-with-body", &server.url("/x")])
.output()
.expect("spawn rsurl");
assert_eq!(out.status.code(), Some(22));
assert_eq!(out.stdout, b"err-body");
}
#[test]
fn cli_proto_restricts_scheme() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "--proto", "=https", "http://example.invalid/"])
.output()
.expect("spawn rsurl");
assert_eq!(out.status.code(), Some(1));
}
#[test]
fn cli_schemeless_url_defaults_to_http() {
use std::process::Command;
let server = TestServer::start(|_r: SReq| SResp::ok("defaulted"));
let port = server.addr.port();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--resolve",
&format!("h.invalid:{port}:127.0.0.1"),
&format!("h.invalid:{port}/"),
])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"defaulted");
}
#[test]
fn cli_time_cond_sends_if_modified_since() {
use std::process::Command;
use std::sync::{Arc, Mutex};
let cap: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let c2 = Arc::clone(&cap);
let server = TestServer::start(move |req: SReq| {
*c2.lock().unwrap() = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("if-modified-since"))
.map(|(_, v)| v.clone());
SResp::ok("ok")
});
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-z",
"Sun, 06 Nov 1994 08:49:37 GMT",
&server.url("/"),
])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
assert_eq!(
cap.lock().unwrap().as_deref(),
Some("Sun, 06 Nov 1994 08:49:37 GMT")
);
}
#[test]
fn cli_url_globbing_expands_range() {
use std::process::Command;
let server = TestServer::start(|req: SReq| SResp::ok(req.path.clone()));
let url = format!("{}[1-3]", server.url("/p"));
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", &url])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"/p1/p2/p3");
}
#[test]
fn cli_globoff_keeps_brackets() {
use std::process::Command;
let server = TestServer::start(|req: SReq| SResp::ok(req.path.clone()));
let url = format!("{}[1-3]", server.url("/p"));
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "-g", &url])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
assert_eq!(out.stdout, b"/p[1-3]");
}
#[test]
fn cli_post302_preserves_method() {
use std::process::Command;
let server = TestServer::start(|req: SReq| {
if req.path == "/a" {
let mut r = SResp::status(302);
r.headers.push(("Location".into(), "/b".into()));
r
} else {
SResp::ok(req.method.clone())
}
});
let kept = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "-L", "--post302", "-d", "x=1", &server.url("/a")])
.output()
.expect("spawn");
assert_eq!(
kept.stdout,
b"POST",
"stderr: {}",
String::from_utf8_lossy(&kept.stderr)
);
let downgraded = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "-L", "-d", "x=1", &server.url("/a")])
.output()
.expect("spawn");
assert_eq!(downgraded.stdout, b"GET");
}
#[test]
fn cli_connect_to_redirects_dial_keeps_host() {
use std::process::Command;
let server = TestServer::start(|req: SReq| {
let host = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("host"))
.map(|(_, v)| v.clone())
.unwrap_or_default();
SResp::ok(host)
});
let port = server.addr.port();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--connect-to",
&format!("origin.invalid:80:127.0.0.1:{port}"),
"http://origin.invalid/",
])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"origin.invalid");
}
#[cfg(unix)]
#[test]
fn cli_unix_socket_transport() {
use std::io::{Read, Write};
use std::os::unix::net::UnixListener;
use std::process::Command;
let dir = std::env::temp_dir().join(format!("rsurl-uds-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let sock = dir.join("s.sock");
let listener = UnixListener::bind(&sock).unwrap();
let handle = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let mut buf = Vec::new();
let mut b = [0u8; 1];
loop {
if s.read(&mut b).unwrap() == 0 {
break;
}
buf.push(b[0]);
if buf.ends_with(b"\r\n\r\n") {
break;
}
}
s.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nUDS!")
.unwrap();
});
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--unix-socket",
sock.to_str().unwrap(),
"http://localhost/",
])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"UDS!");
handle.join().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cli_smtp_send() {
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::process::Command;
use std::sync::{Arc, Mutex};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let captured: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let cap = Arc::clone(&captured);
let handle = std::thread::spawn(move || {
fn line(r: &mut BufReader<std::net::TcpStream>) -> String {
let mut l = String::new();
r.read_line(&mut l).unwrap();
l.trim_end().to_string()
}
let (s, _) = listener.accept().unwrap();
let mut w = s.try_clone().unwrap();
let mut r = BufReader::new(s);
w.write_all(b"220 mock ESMTP\r\n").unwrap();
let ehlo = line(&mut r);
cap.lock().unwrap().push(ehlo);
w.write_all(b"250-mock\r\n250 AUTH PLAIN\r\n").unwrap();
let mf = line(&mut r);
cap.lock().unwrap().push(mf);
w.write_all(b"250 ok\r\n").unwrap();
let rcpt = line(&mut r);
cap.lock().unwrap().push(rcpt);
w.write_all(b"250 ok\r\n").unwrap();
let _data = line(&mut r); w.write_all(b"354 go ahead\r\n").unwrap();
let mut body = String::new();
loop {
let mut l = String::new();
r.read_line(&mut l).unwrap();
if l == ".\r\n" || l == ".\n" {
break;
}
body.push_str(&l);
}
cap.lock()
.unwrap()
.push(format!("BODY:{}", body.trim_end()));
w.write_all(b"250 queued\r\n").unwrap();
let _ = line(&mut r); w.write_all(b"221 bye\r\n").unwrap();
let _ = r.read(&mut [0u8; 1]);
});
let mut msg = std::env::temp_dir();
msg.push(format!("rsurl-smtp-{}.txt", std::process::id()));
std::fs::write(&msg, b"Subject: hi\r\n\r\nHello over SMTP\r\n").unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--mail-from",
"alice@example.com",
"--mail-rcpt",
"bob@example.com",
"-T",
msg.to_str().unwrap(),
&format!("smtp://127.0.0.1:{}", addr.port()),
])
.output()
.expect("spawn rsurl");
let _ = std::fs::remove_file(&msg);
handle.join().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let c = captured.lock().unwrap();
assert!(c.iter().any(|l| l.starts_with("EHLO ")), "got: {c:?}");
assert!(
c.iter().any(|l| l == "MAIL FROM:<alice@example.com>"),
"got: {c:?}"
);
assert!(
c.iter().any(|l| l == "RCPT TO:<bob@example.com>"),
"got: {c:?}"
);
assert!(
c.iter().any(|l| l.contains("Hello over SMTP")),
"got: {c:?}"
);
}
#[test]
fn cli_telnet_strips_iac_and_refuses() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::process::Command;
use std::sync::{Arc, Mutex};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let got = Arc::new(Mutex::new(Vec::<u8>::new()));
let g2 = Arc::clone(&got);
let handle = std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
s.write_all(&[255, 251, 1]).unwrap();
s.write_all(b"banner\r\n").unwrap();
s.flush().unwrap();
let mut buf = [0u8; 16];
if let Ok(n) = s.read(&mut buf) {
g2.lock().unwrap().extend_from_slice(&buf[..n]);
}
});
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", &format!("telnet://127.0.0.1:{}", addr.port())])
.output()
.expect("spawn rsurl");
handle.join().unwrap();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(
out.stdout, b"banner\r\n",
"IAC should be stripped from output"
);
assert_eq!(
&*got.lock().unwrap(),
&[255, 254, 1],
"should refuse with IAC DONT 1"
);
}
#[test]
fn cli_streamed_download_to_file() {
use std::process::Command;
let chunk = vec![b'z'; 16 * 1024];
let chunks: Vec<Vec<u8>> = (0..7).map(|_| chunk.clone()).collect();
let total: usize = chunks.iter().map(|c| c.len()).sum();
let server = TestServer::start(move |_r: SReq| {
SResp::ok(Vec::new()).mode(BodyMode::Chunked {
chunks: chunks.clone(),
trailers: vec![],
})
});
let dir = std::env::temp_dir().join(format!("rsurl-dl-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out_path = dir.join("big.bin");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "-o", out_path.to_str().unwrap(), &server.url("/big")])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let saved = std::fs::read(&out_path).unwrap();
assert_eq!(saved.len(), total);
assert!(saved.iter().all(|&b| b == b'z'));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cli_max_filesize_aborts_stream() {
use std::process::Command;
let server = TestServer::start(|_r: SReq| SResp::ok(vec![b'x'; 50_000]));
let dir = std::env::temp_dir().join(format!("rsurl-mfs-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let out_path = dir.join("capped.bin");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--max-filesize",
"1000",
"-o",
out_path.to_str().unwrap(),
&server.url("/big"),
])
.output()
.expect("spawn rsurl");
assert_eq!(
out.status.code(),
Some(63),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cli_digest_auth() {
use std::process::Command;
use std::sync::{Arc, Mutex};
let auth_seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let a2 = Arc::clone(&auth_seen);
let server = TestServer::start(move |req: SReq| {
let auth = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
.map(|(_, v)| v.clone());
match auth {
Some(a) if a.starts_with("Digest") => {
*a2.lock().unwrap() = Some(a);
SResp::ok("authed")
}
_ => {
let mut r = SResp::status(401);
r.headers.push((
"WWW-Authenticate".into(),
"Digest realm=\"test\", nonce=\"abc123\", qop=\"auth\"".into(),
));
r
}
}
});
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "--digest", "-u", "alice:secret", &server.url("/")])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"authed");
let a = auth_seen
.lock()
.unwrap()
.clone()
.expect("digest header sent");
assert!(a.contains("username=\"alice\""), "got: {a}");
assert!(a.contains("realm=\"test\""), "got: {a}");
assert!(a.contains("qop=auth"), "got: {a}");
assert!(a.contains("response=\""), "got: {a}");
}
#[test]
fn cli_oauth2_bearer() {
use std::process::Command;
use std::sync::{Arc, Mutex};
let cap: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let c2 = Arc::clone(&cap);
let server = TestServer::start(move |req: SReq| {
*c2.lock().unwrap() = req
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
.map(|(_, v)| v.clone());
SResp::ok("ok")
});
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "--oauth2-bearer", "tok123", &server.url("/")])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
assert_eq!(cap.lock().unwrap().as_deref(), Some("Bearer tok123"));
}
#[test]
fn cli_parallel_glob_downloads() {
use std::process::Command;
let server = TestServer::start_keepalive(|req: SReq| SResp::ok(req.path.clone()));
let dir = std::env::temp_dir().join(format!("rsurl-par-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let url = format!("{}[1-4]", server.url("/p"));
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.current_dir(&dir)
.args(["-s", "-Z", "-o", "#1.out", &url])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
for n in 1..=4 {
let got = std::fs::read(dir.join(format!("{n}.out"))).expect("file present");
assert_eq!(got, format!("/p{n}").into_bytes());
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cli_aws_sigv4_signs() {
use std::process::Command;
use std::sync::{Arc, Mutex};
let hdrs: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let h2 = Arc::clone(&hdrs);
let server = TestServer::start(move |req: SReq| {
*h2.lock().unwrap() = req.headers.clone();
SResp::ok("ok")
});
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--aws-sigv4",
"aws:amz:us-east-1:s3",
"-u",
"AKID:SECRET",
&server.url("/bucket/key"),
])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
let h = hdrs.lock().unwrap();
let get = |n: &str| {
h.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(n))
.map(|(_, v)| v.clone())
};
let auth = get("authorization").expect("authorization header");
assert!(auth.starts_with("AWS4-HMAC-SHA256 "), "got: {auth}");
assert!(auth.contains("Credential=AKID/"));
assert!(auth.contains("/us-east-1/s3/aws4_request"));
assert!(get("x-amz-date").is_some());
assert!(get("x-amz-content-sha256").is_some());
}
#[test]
fn cli_low_speed_abort_exits_28() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::process::Command;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n");
let _ = sock.flush();
let _ = sock.write_all(b"01234");
let _ = sock.flush();
std::thread::sleep(Duration::from_millis(1300));
let _ = sock.write_all(b"56789");
let _ = sock.flush();
std::thread::sleep(Duration::from_millis(1300));
});
let mut out_path = std::env::temp_dir();
out_path.push(format!("rsurl-lowspeed-{}.bin", std::process::id()));
let status = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-Y",
"1000000", "-y",
"1", "-o",
out_path.to_str().unwrap(),
&format!("http://{addr}/slow"),
])
.status()
.expect("spawn rsurl");
assert_eq!(status.code(), Some(28), "expected low-speed exit code 28");
let _ = handle.join();
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn cli_compat_noop_flags_accepted() {
use std::process::Command;
let server = TestServer::start(|_req: SReq| SResp::ok("ok"));
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-q",
"--no-progress-meter",
"-N",
"--styled-output",
"--no-styled-output",
"--basic",
"--ftp-skip-pasv-ip",
"--ftp-pasv",
"-s",
&server.url("/"),
])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(out.stdout, b"ok");
}
#[test]
fn cli_write_out_phase_timers() {
use std::process::Command;
let server = TestServer::start(|_req: SReq| SResp::ok("hello"));
let out_path = tmp_out_path("wo-timers");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-o",
out_path.to_str().unwrap(),
"-w",
"%{time_connect} %{time_starttransfer} %{time_total}",
&server.url("/"),
])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
let line = String::from_utf8_lossy(&out.stdout);
let nums: Vec<f64> = line
.split_whitespace()
.map(|s| s.parse::<f64>().expect("write-out timer is a float"))
.collect();
assert_eq!(nums.len(), 3, "got: {line:?}");
assert!(nums[1] > 0.0, "starttransfer should be measured: {line:?}");
assert!(
nums[0] <= nums[1] + 1e-6,
"connect<=starttransfer: {line:?}"
);
assert!(nums[1] <= nums[2] + 1e-6, "starttransfer<=total: {line:?}");
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn cli_write_out_header_var() {
use std::process::Command;
let server = TestServer::start(|_req: SReq| SResp::ok("body").header("X-Test", "abc123"));
let out_path = tmp_out_path("wo-header");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-o",
out_path.to_str().unwrap(),
"-w",
"[%header{X-Test}|%{ssl_verify_result}]",
&server.url("/"),
])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout), "[abc123|0]");
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn cli_exit_codes_connect_and_url() {
use std::process::Command;
let refused = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "http://127.0.0.1:1/"])
.status()
.expect("spawn rsurl");
assert_eq!(refused.code(), Some(7), "connection refused should exit 7");
let bad = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "http://"])
.status()
.expect("spawn rsurl");
assert_eq!(bad.code(), Some(3), "malformed URL should exit 3");
}
#[test]
fn cli_json_flag_sets_content_type_and_accept() {
use std::process::Command;
use std::sync::{Arc, Mutex};
type Cap = Arc<Mutex<Option<(String, Vec<(String, String)>, Vec<u8>)>>>;
let cap: Cap = Arc::new(Mutex::new(None));
let c2 = Arc::clone(&cap);
let server = TestServer::start(move |req: SReq| {
*c2.lock().unwrap() = Some((req.method.clone(), req.headers.clone(), req.body.clone()));
SResp::ok("ok")
});
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-s", "--json", r#"{"a":1}"#, &server.url("/")])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
let g = cap.lock().unwrap();
let (method, headers, body) = g.as_ref().expect("request captured");
assert_eq!(method, "POST");
assert_eq!(body, br#"{"a":1}"#);
let hv = |n: &str| {
headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(n))
.map(|(_, v)| v.as_str())
};
assert_eq!(hv("content-type"), Some("application/json"));
assert_eq!(hv("accept"), Some("application/json"));
}
#[test]
fn cli_no_clobber_picks_suffix() {
use std::process::Command;
let server = TestServer::start(|_req: SReq| SResp::ok("fresh"));
let base = tmp_out_path("noclobber");
std::fs::write(&base, b"original").unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--no-clobber",
"-o",
base.to_str().unwrap(),
&server.url("/"),
])
.output()
.expect("spawn rsurl");
assert!(out.status.success());
assert_eq!(std::fs::read(&base).unwrap(), b"original");
let alt = std::path::PathBuf::from(format!("{}.1", base.to_str().unwrap()));
assert_eq!(std::fs::read(&alt).unwrap(), b"fresh");
let _ = std::fs::remove_file(&base);
let _ = std::fs::remove_file(&alt);
}
#[test]
fn cli_remove_on_error_deletes_partial() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::process::Command;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
let _ = sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n0123456789");
let _ = sock.flush();
});
let out_path = tmp_out_path("removeonerr");
let status = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--remove-on-error",
"-o",
out_path.to_str().unwrap(),
&format!("http://{addr}/x"),
])
.status()
.expect("spawn rsurl");
let _ = handle.join();
assert!(!status.success(), "truncated body should fail");
assert!(
!out_path.exists(),
"--remove-on-error must delete the partial file"
);
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn cli_ftp_download_streams_to_file() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::process::Command;
let ctrl = TcpListener::bind("127.0.0.1:0").unwrap();
let ctrl_port = ctrl.local_addr().unwrap().port();
let data = TcpListener::bind("127.0.0.1:0").unwrap();
let data_port = data.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let (sock, _) = ctrl.accept().unwrap();
let mut w = sock.try_clone().unwrap();
let mut r = BufReader::new(sock);
w.write_all(b"220 mock ready\r\n").unwrap();
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap() == 0 {
break;
}
let cmd = line.trim_end();
if cmd.starts_with("USER") {
w.write_all(b"331 need password\r\n").unwrap();
} else if cmd.starts_with("PASS") {
w.write_all(b"230 logged in\r\n").unwrap();
} else if cmd.starts_with("EPSV") {
w.write_all(
format!("229 Entering Extended Passive Mode (|||{data_port}|)\r\n").as_bytes(),
)
.unwrap();
} else if cmd.starts_with("RETR") {
w.write_all(b"150 opening data connection\r\n").unwrap();
let (mut d, _) = data.accept().unwrap();
d.write_all(b"FTP-STREAMED-BODY").unwrap();
drop(d); w.write_all(b"226 transfer complete\r\n").unwrap();
} else if cmd.starts_with("QUIT") {
w.write_all(b"221 bye\r\n").unwrap();
break;
} else {
w.write_all(b"200 ok\r\n").unwrap();
}
}
});
let out_path = tmp_out_path("ftp-dl");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-o",
out_path.to_str().unwrap(),
"-w",
"%{size_download}",
&format!("ftp://127.0.0.1:{ctrl_port}/file.txt"),
])
.output()
.expect("spawn rsurl");
let _ = handle.join();
assert!(out.status.success(), "ftp download should succeed");
assert_eq!(std::fs::read(&out_path).unwrap(), b"FTP-STREAMED-BODY");
assert_eq!(String::from_utf8_lossy(&out.stdout), "17");
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn cli_ftp_create_dirs_upload() {
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::process::Command;
use std::sync::{Arc, Mutex};
let ctrl = TcpListener::bind("127.0.0.1:0").unwrap();
let ctrl_port = ctrl.local_addr().unwrap().port();
let data = TcpListener::bind("127.0.0.1:0").unwrap();
let data_port = data.local_addr().unwrap().port();
let seen: Arc<Mutex<(Vec<String>, Vec<u8>)>> = Arc::new(Mutex::new((Vec::new(), Vec::new())));
let seen2 = Arc::clone(&seen);
let handle = std::thread::spawn(move || {
let (sock, _) = ctrl.accept().unwrap();
let mut w = sock.try_clone().unwrap();
let mut r = BufReader::new(sock);
w.write_all(b"220 mock\r\n").unwrap();
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap() == 0 {
break;
}
let cmd = line.trim_end();
if cmd.starts_with("USER") {
w.write_all(b"331 pw\r\n").unwrap();
} else if cmd.starts_with("PASS") {
w.write_all(b"230 ok\r\n").unwrap();
} else if let Some(dir) = cmd.strip_prefix("MKD ") {
seen2.lock().unwrap().0.push(dir.to_string());
w.write_all(b"257 created\r\n").unwrap();
} else if cmd.starts_with("EPSV") {
w.write_all(
format!("229 Entering Extended Passive Mode (|||{data_port}|)\r\n").as_bytes(),
)
.unwrap();
} else if cmd.starts_with("STOR") {
w.write_all(b"150 send it\r\n").unwrap();
let (mut d, _) = data.accept().unwrap();
let mut body = Vec::new();
d.read_to_end(&mut body).unwrap();
seen2.lock().unwrap().1 = body;
w.write_all(b"226 stored\r\n").unwrap();
} else if cmd.starts_with("QUIT") {
w.write_all(b"221 bye\r\n").unwrap();
break;
} else {
w.write_all(b"200 ok\r\n").unwrap();
}
}
});
let mut tmp = std::env::temp_dir();
tmp.push(format!("rsurl-ftp-up-{}.bin", std::process::id()));
std::fs::write(&tmp, b"UPLOAD-PAYLOAD").unwrap();
let status = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"--ftp-create-dirs",
"-T",
tmp.to_str().unwrap(),
&format!("ftp://127.0.0.1:{ctrl_port}/a/b/file.bin"),
])
.status()
.expect("spawn rsurl");
let _ = handle.join();
assert!(status.success(), "ftp upload should succeed");
let g = seen.lock().unwrap();
assert_eq!(
g.0,
vec!["a".to_string(), "a/b".to_string()],
"MKD prefixes"
);
assert_eq!(g.1, b"UPLOAD-PAYLOAD");
let _ = std::fs::remove_file(&tmp);
}
#[test]
#[cfg(unix)]
fn cli_file_scheme_download_to_file() {
use std::process::Command;
let mut src = std::env::temp_dir();
src.push(format!("rsurl-file-src-{}.txt", std::process::id()));
std::fs::write(&src, b"LOCAL-FILE-CONTENTS").unwrap();
let out_path = tmp_out_path("file-dl");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-o",
out_path.to_str().unwrap(),
"-w",
"%{size_download}",
&format!("file://{}", src.to_str().unwrap()),
])
.output()
.expect("spawn rsurl");
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(&out_path).unwrap(), b"LOCAL-FILE-CONTENTS");
assert_eq!(String::from_utf8_lossy(&out.stdout), "19");
let _ = std::fs::remove_file(&src);
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn cli_streaming_gzip_decode_to_file() {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::process::Command;
let gz: [u8; 41] = [
31, 139, 8, 0, 0, 0, 0, 0, 0, 3, 11, 14, 9, 114, 117, 244, 213, 117, 143, 242, 12, 208,
117, 113, 117, 246, 119, 113, 213, 245, 247, 6, 0, 182, 187, 1, 85, 21, 0, 0, 0,
];
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf);
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\n\r\n",
gz.len()
);
let _ = sock.write_all(head.as_bytes());
let _ = sock.write_all(&gz);
let _ = sock.flush();
});
let out_path = tmp_out_path("gz-stream");
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-o",
out_path.to_str().unwrap(),
"-w",
"%{size_download}",
&format!("http://{addr}/g"),
])
.output()
.expect("spawn rsurl");
let _ = handle.join();
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(std::fs::read(&out_path).unwrap(), b"STREAM-GZIP-DECODE-OK");
assert_eq!(String::from_utf8_lossy(&out.stdout), "21");
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn cli_ftp_active_mode_download() {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::process::Command;
let ctrl = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let ctrl_port = ctrl.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let (sock, _) = ctrl.accept().unwrap();
let mut w = sock.try_clone().unwrap();
let mut r = BufReader::new(sock);
w.write_all(b"220 mock\r\n").unwrap();
let mut data_port: u16 = 0;
loop {
let mut line = String::new();
if r.read_line(&mut line).unwrap() == 0 {
break;
}
let cmd = line.trim_end();
if cmd.starts_with("USER") {
w.write_all(b"331 pw\r\n").unwrap();
} else if cmd.starts_with("PASS") {
w.write_all(b"230 ok\r\n").unwrap();
} else if let Some(rest) = cmd.strip_prefix("EPRT ") {
let parts: Vec<&str> = rest.trim_matches('|').split('|').collect();
data_port = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
w.write_all(b"200 EPRT ok\r\n").unwrap();
} else if cmd.starts_with("RETR") {
w.write_all(b"150 opening\r\n").unwrap();
let mut d = TcpStream::connect(("127.0.0.1", data_port)).unwrap();
d.write_all(b"ACTIVE-MODE-BODY").unwrap();
drop(d);
w.write_all(b"226 done\r\n").unwrap();
} else if cmd.starts_with("QUIT") {
w.write_all(b"221 bye\r\n").unwrap();
break;
} else {
w.write_all(b"200 ok\r\n").unwrap();
}
}
});
let out_path = tmp_out_path("ftp-active");
let status = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args([
"-s",
"-P",
"-",
"-o",
out_path.to_str().unwrap(),
&format!("ftp://127.0.0.1:{ctrl_port}/file.txt"),
])
.status()
.expect("spawn rsurl");
let _ = handle.join();
assert!(status.success(), "active-mode ftp download should succeed");
assert_eq!(std::fs::read(&out_path).unwrap(), b"ACTIVE-MODE-BODY");
let _ = std::fs::remove_file(&out_path);
}
#[test]
fn ffi_easy_extended_options() {
use rsurl::ffi::{
rsurl_easy_cleanup, rsurl_easy_init, rsurl_easy_perform, rsurl_easy_response_body,
rsurl_easy_response_status, rsurl_easy_setopt_long, rsurl_easy_setopt_str,
};
use std::ffi::CString;
use std::sync::{Arc, Mutex};
let seen: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
let s2 = Arc::clone(&seen);
let server = TestServer::start(move |req: SReq| {
*s2.lock().unwrap() = req.headers.clone();
SResp::ok("ffi-ok")
});
let url = CString::new(server.url("/")).unwrap();
let userpwd = CString::new("alice:s3cret").unwrap();
let referer = CString::new("https://ref.example/").unwrap();
unsafe {
let h = rsurl_easy_init();
assert!(!h.is_null());
assert_eq!(rsurl_easy_setopt_str(h, 1, url.as_ptr()) as i32, 0);
assert_eq!(rsurl_easy_setopt_str(h, 11, userpwd.as_ptr()) as i32, 0);
assert_eq!(rsurl_easy_setopt_str(h, 14, referer.as_ptr()) as i32, 0);
assert_eq!(rsurl_easy_setopt_long(h, 9, 1) as i32, 0);
assert_eq!(rsurl_easy_setopt_long(h, 12, 1) as i32, 0);
assert_eq!(rsurl_easy_perform(h) as i32, 0);
assert_eq!(rsurl_easy_response_status(h), 200);
let mut ptr: *const u8 = std::ptr::null();
let mut len: usize = 0;
assert_eq!(rsurl_easy_response_body(h, &mut ptr, &mut len) as i32, 0);
let body = std::slice::from_raw_parts(ptr, len);
assert_eq!(body, b"ffi-ok");
rsurl_easy_cleanup(h);
}
let h = seen.lock().unwrap();
let get = |n: &str| {
h.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(n))
.map(|(_, v)| v.clone())
};
assert_eq!(
get("authorization").as_deref(),
Some("Basic YWxpY2U6czNjcmV0")
);
assert_eq!(get("referer").as_deref(), Some("https://ref.example/"));
}