#[cfg(test)]
mod tests;
use native_tls::TlsConnector;
use std::{
env,
fs::File,
io::{ErrorKind, Read, Write},
net::{TcpStream, ToSocketAddrs},
process, thread,
time::Duration,
};
struct Args {
url: String,
output: Option<String>,
method: String,
headers: Vec<String>,
data: Option<String>,
help: bool,
verbose: bool,
}
impl Args {
fn parse() -> Result<Self, &'static str> {
let mut args = env::args().skip(1);
let mut parsed = Args {
url: String::new(),
output: None,
method: "GET".to_string(),
headers: Vec::new(),
data: None,
help: false,
verbose: false,
};
while let Some(arg) = args.next() {
match arg.as_str() {
"-h" | "--help" => {
parsed.help = true;
return Ok(parsed);
}
"-v" | "--verbose" => {
parsed.verbose = true;
}
"-o" | "--output" => {
parsed.output = Some(args.next().ok_or("Missing output file")?);
}
"-m" | "--method" => {
parsed.method = args.next().ok_or("Missing HTTP method")?.to_uppercase();
}
"-H" | "--header" => {
parsed.headers.push(args.next().ok_or("Missing header")?);
}
"-d" | "--data" => {
parsed.data = Some(args.next().ok_or("Missing data")?);
}
_ if arg.starts_with('-') => {
return Err("Unknown option");
}
_ => {
parsed.url = arg;
}
}
}
if parsed.url.is_empty() && !parsed.help {
return Err("Missing URL");
}
Ok(parsed)
}
}
fn parse_url(url: &str) -> Result<(String, u16, String, bool), &'static str> {
let (protocol, rest) = if url.starts_with("https://") {
(true, url.trim_start_matches("https://"))
} else if url.starts_with("http://") {
(false, url.trim_start_matches("http://"))
} else {
return Err("URL must start with http:// or https://");
};
let (host, path) = rest.split_once('/').unwrap_or((rest, ""));
let (host, port) = if let Some((host, port)) = host.split_once(':') {
(host, port.parse().map_err(|_| "Invalid port")?)
} else {
(host, if protocol { 443 } else { 80 })
};
if host.is_empty() {
return Err("Invalid host");
}
Ok((host.to_string(), port, format!("/{}", path), protocol))
}
fn build_http_request(args: &Args) -> Result<Vec<u8>, &'static str> {
let (host, _port, path, _) = parse_url(&args.url)?;
let mut request = format!(
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n",
args.method, path, host
);
for header in &args.headers {
request.push_str(&format!("{}\r\n", header));
}
if let Some(data) = &args.data {
request.push_str(&format!("Content-Length: {}\r\n", data.len()));
}
request.push_str("\r\n");
let mut request_bytes = request.into_bytes();
if let Some(data) = &args.data {
request_bytes.extend_from_slice(data.as_bytes());
}
Ok(request_bytes)
}
fn get_content_length(response: &[u8]) -> Option<usize> {
let headers = std::str::from_utf8(&response[..std::cmp::min(response.len(), 2048)]).ok()?;
for line in headers.lines() {
let line = line.trim().to_lowercase();
if line.starts_with("content-length:") {
let value = line.split(':').nth(1)?.trim().parse::<usize>().ok()?;
return Some(value);
}
}
None
}
fn is_chunked_transfer(response: &[u8]) -> bool {
if let Ok(headers) = std::str::from_utf8(&response[..std::cmp::min(response.len(), 2048)]) {
for line in headers.lines() {
let line = line.trim().to_lowercase();
if line.starts_with("transfer-encoding:") && line.contains("chunked") {
return true;
}
}
}
false
}
fn parse_status_line(response: &[u8]) -> Result<u16, &'static str> {
let status_line = match response.split(|&b| b == b'\r').next() {
Some(line) => line,
None => return Err("Invalid response format"),
};
let status_line = match std::str::from_utf8(status_line) {
Ok(line) => line,
Err(_) => return Err("Invalid UTF-8 in status line"),
};
let status_code = status_line
.split_whitespace()
.nth(1)
.ok_or("Missing status code")?
.parse::<u16>()
.map_err(|_| "Invalid status code")?;
Ok(status_code)
}
fn decode_chunked_transfer(body: &[u8]) -> Vec<u8> {
let mut result = Vec::new();
let mut i = 0;
while i < body.len() {
let chunk_size_end = match &body[i..].windows(2).position(|w| w == b"\r\n") {
Some(pos) => i + pos,
None => break, };
if chunk_size_end == i {
break; }
let chunk_size_line = std::str::from_utf8(&body[i..chunk_size_end]).unwrap_or("");
let chunk_size = match usize::from_str_radix(chunk_size_line.trim(), 16) {
Ok(size) => size,
Err(_) => break, };
if chunk_size == 0 {
break;
}
let chunk_start = chunk_size_end + 2;
if chunk_start + chunk_size > body.len() {
break;
}
result.extend_from_slice(&body[chunk_start..chunk_start + chunk_size]);
i = chunk_start + chunk_size + 2;
}
result
}
fn main() {
let args = match Args::parse() {
Ok(args) => args,
Err(err) => {
eprintln!("Error: {}", err);
eprintln!("Usage: rurl [OPTIONS] <URL>");
eprintln!("Try 'rurl --help' for more information.");
process::exit(1);
}
};
if args.help {
println!("rurl - A minimal HTTP client");
println!();
println!("Usage:");
println!(" rurl [OPTIONS] <URL>");
println!();
println!("Options:");
println!(" -o, --output <FILE> Save the response body to a file");
println!(" -m, --method <METHOD> HTTP method to use (default: GET)");
println!(" -H, --header <HEADER> Add a header to the request");
println!(" -d, --data <DATA> Add data to the request body");
println!(" -v, --verbose Enable verbose output");
println!(" -h, --help Display this help message");
println!();
println!("Examples:");
println!(" rurl https://example.com");
println!(" rurl -m POST -H \"Content-Type: application/json\" -d '{{\"key\":\"value\"}}' https://api.example.com");
println!(" rurl -o response.html https://example.com");
process::exit(0);
}
let request_bytes = match build_http_request(&args) {
Ok(bytes) => bytes,
Err(err) => {
eprintln!("Error: {}", err);
process::exit(1);
}
};
let (host, port, _, is_https) = match parse_url(&args.url) {
Ok(parsed) => parsed,
Err(err) => {
eprintln!("Error: {}", err);
process::exit(1);
}
};
let addr = format!("{}:{}", host, port);
let addrs = match addr.to_socket_addrs() {
Ok(addrs) => addrs,
Err(err) => {
eprintln!("DNS resolution error: {}", err);
process::exit(1);
}
};
let mut stream =
match TcpStream::connect_timeout(&addrs.collect::<Vec<_>>()[0], Duration::from_secs(10)) {
Ok(stream) => {
if let Err(err) = stream.set_read_timeout(Some(Duration::from_secs(30))) {
eprintln!("Failed to set read timeout: {}", err);
process::exit(1);
}
if let Err(err) = stream.set_write_timeout(Some(Duration::from_secs(10))) {
eprintln!("Failed to set write timeout: {}", err);
process::exit(1);
}
stream
}
Err(err) => {
eprintln!("Connection error: {} ({}:{})", err, host, port);
process::exit(1);
}
};
if is_https {
let connector = match TlsConnector::builder()
.danger_accept_invalid_certs(false)
.danger_accept_invalid_hostnames(false)
.min_protocol_version(Some(native_tls::Protocol::Tlsv12))
.build()
{
Ok(connector) => connector,
Err(err) => {
eprintln!("TLS error: {}", err);
process::exit(1);
}
};
if args.verbose {
println!("Connecting to {} (HTTPS)...", host);
}
let mut tls_stream = match connector.connect(&host, stream) {
Ok(stream) => stream,
Err(err) => {
eprintln!("TLS handshake error: {}", err);
process::exit(1);
}
};
if args.verbose {
println!("Sending request...");
println!("Waiting for response...");
}
if let Err(err) = tls_stream.write_all(&request_bytes) {
eprintln!("Write error: {}", err);
process::exit(1);
}
let mut response = Vec::with_capacity(1024 * 1024); let mut buffer = [0u8; 8192]; let mut total_read = 0;
const MAX_SIZE: usize = 10 * 1024 * 1024; let mut attempts = 0;
const MAX_ATTEMPTS: usize = 50;
while attempts < MAX_ATTEMPTS {
match tls_stream.read(&mut buffer) {
Ok(0) => {
if attempts > 0 {
break;
}
if args.verbose {
println!("No data received, retrying...");
}
thread::sleep(Duration::from_millis(100));
attempts += 1;
continue;
}
Ok(n) => {
attempts = 0; total_read += n;
response.extend_from_slice(&buffer[..n]);
if let Some(header_end) =
response.windows(4).position(|window| window == b"\r\n\r\n")
{
let content_length = get_content_length(&response[..header_end + 4]);
if let Some(length) = content_length {
if args.verbose {
println!("Response Content-Length: {} bytes", length);
}
let expected_size = header_end + 4 + length;
if response.len() >= expected_size {
if args.verbose {
println!("Response complete based on Content-Length");
}
break;
}
} else if is_chunked_transfer(&response[..header_end + 4]) {
if response.windows(5).any(|window| window == b"0\r\n\r\n") {
if args.verbose {
println!("Chunked response complete");
}
break;
}
}
}
if total_read > MAX_SIZE {
eprintln!("Response too large, truncating at {} bytes", MAX_SIZE);
break;
}
}
Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => {
if !response.is_empty() {
attempts += 1;
if attempts >= 5 {
if args.verbose {
println!(
"No more data after {} attempts, considering response complete",
attempts
);
}
break;
}
}
thread::sleep(Duration::from_millis(100));
continue;
}
Err(err) => {
eprintln!("Read error: {}", err);
if !response.is_empty() {
if args.verbose {
println!("Processing partial response of {} bytes", response.len());
}
break;
}
process::exit(1);
}
}
}
if attempts >= MAX_ATTEMPTS && response.is_empty() {
eprintln!("No response received after maximum attempts");
process::exit(1);
}
if args.verbose {
println!("Received {} bytes", response.len());
}
process_response(&response, &args);
} else {
if args.verbose {
println!("Connecting to {} (HTTP)...", host);
}
if let Err(err) = stream.write_all(&request_bytes) {
eprintln!("Write error: {}", err);
process::exit(1);
}
if args.verbose {
println!("Sending request...");
println!("Waiting for response...");
}
let mut response = Vec::with_capacity(1024 * 1024); let mut buffer = [0u8; 8192]; let mut total_read = 0;
const MAX_SIZE: usize = 10 * 1024 * 1024; let mut attempts = 0;
const MAX_ATTEMPTS: usize = 50;
while attempts < MAX_ATTEMPTS {
match stream.read(&mut buffer) {
Ok(0) => {
if attempts > 0 {
break;
}
if args.verbose {
println!("No data received, retrying...");
}
thread::sleep(Duration::from_millis(100));
attempts += 1;
continue;
}
Ok(n) => {
attempts = 0; total_read += n;
response.extend_from_slice(&buffer[..n]);
if let Some(header_end) =
response.windows(4).position(|window| window == b"\r\n\r\n")
{
let content_length = get_content_length(&response[..header_end + 4]);
if let Some(length) = content_length {
if args.verbose {
println!("Response Content-Length: {} bytes", length);
}
let expected_size = header_end + 4 + length;
if response.len() >= expected_size {
if args.verbose {
println!("Response complete based on Content-Length");
}
break;
}
} else if is_chunked_transfer(&response[..header_end + 4]) {
if response.windows(5).any(|window| window == b"0\r\n\r\n") {
if args.verbose {
println!("Chunked response complete");
}
break;
}
}
}
if total_read > MAX_SIZE {
eprintln!("Response too large, truncating at {} bytes", MAX_SIZE);
break;
}
}
Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => {
if !response.is_empty() {
attempts += 1;
if attempts >= 5 {
if args.verbose {
println!(
"No more data after {} attempts, considering response complete",
attempts
);
}
break;
}
}
thread::sleep(Duration::from_millis(100));
continue;
}
Err(err) => {
eprintln!("Read error: {}", err);
if !response.is_empty() {
if args.verbose {
println!("Processing partial response of {} bytes", response.len());
}
break;
}
process::exit(1);
}
}
}
if attempts >= MAX_ATTEMPTS && response.is_empty() {
eprintln!("No response received after maximum attempts");
process::exit(1);
}
if args.verbose {
println!("Received {} bytes", response.len());
}
process_response(&response, &args);
}
}
fn process_response(response: &[u8], args: &Args) {
let header_end = match response.windows(4).position(|window| window == b"\r\n\r\n") {
Some(pos) => pos + 4,
None => {
eprintln!("Invalid HTTP response");
process::exit(1);
}
};
let status = match parse_status_line(response) {
Ok(status) => status,
Err(err) => {
eprintln!("Error parsing status: {}", err);
process::exit(1);
}
};
if args.verbose {
if let Ok(headers) = std::str::from_utf8(&response[..header_end]) {
let status_line = headers.lines().next().unwrap_or("Unknown status");
println!("Status: {}", status_line);
let mut content_type = None;
let mut content_length = None;
let mut transfer_encoding = None;
for line in headers.lines().skip(1) {
let lower_line = line.to_lowercase();
if lower_line.starts_with("content-type:") {
content_type = Some(line);
} else if lower_line.starts_with("content-length:") {
content_length = Some(line);
} else if lower_line.starts_with("transfer-encoding:") {
transfer_encoding = Some(line);
}
}
if let Some(ct) = content_type {
println!("{}", ct);
}
if let Some(cl) = content_length {
println!("{}", cl);
}
if let Some(te) = transfer_encoding {
println!("{}", te);
}
println!();
}
}
if status >= 400 {
eprintln!("HTTP Error: {}", status);
if let Ok(body) = std::str::from_utf8(&response[header_end..]) {
eprintln!("Response body: {}", body);
}
process::exit(1);
}
let body = if is_chunked_transfer(&response[..header_end]) {
decode_chunked_transfer(&response[header_end..])
} else {
response[header_end..].to_vec()
};
if let Some(output_path) = &args.output {
match File::create(output_path) {
Ok(mut file) => {
if let Err(err) = file.write_all(&body) {
eprintln!("Write error: {}", err);
process::exit(1);
}
println!("Response body saved to '{}'", output_path);
}
Err(err) => {
eprintln!("File error: {}", err);
process::exit(1);
}
}
} else {
let body_str = String::from_utf8_lossy(&body);
println!("{}", body_str);
}
}