mod common;
use std::time::Duration;
use common::{BodyMode, Request as SReq, Response as SResp, TestServer};
use rsurl::{CookieJar, Error, Request};
#[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 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 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 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);
}
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_non_http() {
use std::process::Command;
let out = Command::new(env!("CARGO_BIN_EXE_rsurl"))
.args(["-T", "/etc/hostname", "ftp://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_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", "/etc/hostname", "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", "/etc/hostname", "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"}"#);
}