use std::fs::File;
use std::io::{self, Write};
use std::path::Path;
use std::process::ExitCode;
use std::time::Duration;
use rsurl::{HttpVersionPref, Request, Response, Url};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Default)]
struct Args {
urls: Vec<String>,
output: Option<String>,
include_headers: bool,
head: bool,
verbose: bool,
silent: bool,
method: Option<String>,
headers: Vec<(String, String)>,
data: Option<String>,
user_agent: Option<String>,
referer: Option<String>,
http_version: Option<HttpVersionPref>,
follow_redirects: bool,
max_redirs: Option<u32>,
basic_auth: Option<(String, String)>,
insecure: bool,
cacert: Option<String>,
max_time: Option<u64>,
connect_timeout: Option<u64>,
remote_name: bool,
}
fn main() -> ExitCode {
let raw: Vec<String> = std::env::args().skip(1).collect();
let args = match parse_args(&raw) {
Ok(a) => a,
Err(e) => {
eprintln!("rsurl: {e}");
eprintln!("try 'rsurl --help'");
return ExitCode::from(2);
}
};
if args.urls.is_empty() {
print_usage();
return ExitCode::from(2);
}
let mut last_failure: u8 = 0;
for url in &args.urls {
let code = process_url(url, &args);
if code != 0 {
last_failure = code;
}
}
ExitCode::from(last_failure)
}
fn process_url(url: &str, args: &Args) -> u8 {
let parsed_url = match Url::parse(url) {
Ok(u) => u,
Err(e) => {
if !args.silent {
eprintln!("rsurl: {e}");
}
return 3;
}
};
if !matches!(parsed_url.scheme.as_str(), "http" | "https") {
return run_transfer(url, args);
}
let method = args.method.clone().unwrap_or_else(|| {
if args.head {
"HEAD".to_string()
} else if args.data.is_some() {
"POST".to_string()
} else {
"GET".to_string()
}
});
let mut req = match Request::new(&method, url) {
Ok(r) => r,
Err(e) => {
if !args.silent {
eprintln!("rsurl: {e}");
}
return 3;
}
};
for (k, v) in &args.headers {
req = req.header(k, v);
}
if let Some(ua) = &args.user_agent {
req = req.header("User-Agent", ua);
}
if let Some(rf) = &args.referer {
req = req.header("Referer", rf);
}
if let Some(body) = args.data.as_deref() {
let body_bytes = body.as_bytes().to_vec();
if !args
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("content-type"))
{
req = req.header("Content-Type", "application/x-www-form-urlencoded");
}
req = req.body(body_bytes);
}
match args.http_version {
Some(HttpVersionPref::Http2Only) => req = req.http2_only(),
Some(HttpVersionPref::Http11Only) => req = req.http11_only(),
Some(HttpVersionPref::Auto) | None => {}
}
if args.follow_redirects {
req = req.follow_redirects(true);
}
if let Some(n) = args.max_redirs {
req = req.max_redirs(n);
}
if let Some((u, p)) = &args.basic_auth {
req = req.basic_auth(u, p);
}
if args.insecure {
req = req.verify_tls(false);
}
if let Some(path) = &args.cacert {
req = req.ca_bundle(path);
}
if let Some(secs) = args.max_time {
req = req.max_time(Duration::from_secs(secs));
}
if let Some(secs) = args.connect_timeout {
req = req.connect_timeout(Duration::from_secs(secs));
}
let send_result = if args.verbose {
let mut err = io::stderr().lock();
req.send_traced(&mut err)
} else {
req.send()
};
let resp = match send_result {
Ok(r) => r,
Err(e) => {
if !args.silent {
eprintln!("rsurl: {e}");
}
return 7;
}
};
let exit_for_status: u8 = if (200..400).contains(&resp.status) {
0
} else {
22
};
if let Err(e) = write_output(&resp, &parsed_url, args) {
if !args.silent {
eprintln!("rsurl: write error: {e}");
}
return 23;
}
exit_for_status
}
fn parse_args(raw: &[String]) -> Result<Args, String> {
let mut a = Args::default();
let mut it = raw.iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => {
print_usage();
std::process::exit(0);
}
"-V" | "--version" => {
println!("rsurl {VERSION}");
std::process::exit(0);
}
"-o" | "--output" => {
a.output = Some(next_val(&mut it, arg)?);
}
"-i" | "--include" => a.include_headers = true,
"-I" | "--head" => {
a.head = true;
a.include_headers = true;
}
"-v" | "--verbose" => a.verbose = true,
"-s" | "--silent" => a.silent = true,
"-X" | "--request" => a.method = Some(next_val(&mut it, arg)?),
"-H" | "--header" => {
let h = next_val(&mut it, arg)?;
let (k, v) = h
.split_once(':')
.ok_or_else(|| format!("malformed header: {h:?}"))?;
a.headers.push((k.trim().to_string(), v.trim().to_string()));
}
"-d" | "--data" | "--data-raw" => a.data = Some(next_val(&mut it, arg)?),
"-A" | "--user-agent" => a.user_agent = Some(next_val(&mut it, arg)?),
"-e" | "--referer" => a.referer = Some(next_val(&mut it, arg)?),
"--http2" => a.http_version = Some(HttpVersionPref::Http2Only),
"--http1.1" | "--http1" => a.http_version = Some(HttpVersionPref::Http11Only),
"-L" | "--location" => a.follow_redirects = true,
"--max-redirs" => {
let v = next_val(&mut it, arg)?;
a.max_redirs = Some(
v.parse::<u32>()
.map_err(|_| format!("--max-redirs: not a number: {v:?}"))?,
);
}
"-u" | "--user" => {
let v = next_val(&mut it, arg)?;
let (u, p) = match v.split_once(':') {
Some((u, p)) => (u.to_string(), p.to_string()),
None => (v.clone(), String::new()),
};
a.basic_auth = Some((u, p));
}
"-k" | "--insecure" => a.insecure = true,
"--cacert" => a.cacert = Some(next_val(&mut it, arg)?),
"--max-time" => {
let v = next_val(&mut it, arg)?;
a.max_time = Some(
v.parse::<u64>()
.map_err(|_| format!("--max-time: not a number: {v:?}"))?,
);
}
"--connect-timeout" => {
let v = next_val(&mut it, arg)?;
a.connect_timeout = Some(
v.parse::<u64>()
.map_err(|_| format!("--connect-timeout: not a number: {v:?}"))?,
);
}
"-O" | "--remote-name" => a.remote_name = true,
s if s.starts_with("--") => return Err(format!("unknown option: {s}")),
s if s.starts_with('-') && s.len() > 1 => return Err(format!("unknown option: {s}")),
_ => {
a.urls.push(arg.clone());
}
}
}
Ok(a)
}
fn next_val(it: &mut std::slice::Iter<'_, String>, flag: &str) -> Result<String, String> {
it.next()
.cloned()
.ok_or_else(|| format!("{flag} requires a value"))
}
fn run_transfer(url: &str, args: &Args) -> u8 {
match rsurl::transfer(url) {
Ok(bytes) => {
let mut out: Box<dyn Write> = match &args.output {
Some(path) if path != "-" => match File::create(path) {
Ok(f) => Box::new(f),
Err(e) => {
if !args.silent {
eprintln!("rsurl: open {path}: {e}");
}
return 23;
}
},
_ => Box::new(io::stdout().lock()),
};
if let Err(e) = out.write_all(&bytes) {
if !args.silent {
eprintln!("rsurl: write error: {e}");
}
return 23;
}
0
}
Err(e) => {
if !args.silent {
eprintln!("rsurl: {e}");
}
7
}
}
}
fn write_output(resp: &Response, url: &Url, args: &Args) -> io::Result<()> {
let mut out: Box<dyn Write> = if args.remote_name {
let name = remote_name_from_url(url).map_err(|e| io::Error::other(e.to_string()))?;
Box::new(File::create(&name)?)
} else {
match &args.output {
Some(path) if path != "-" => Box::new(File::create(path)?),
_ => Box::new(io::stdout().lock()),
}
};
if args.include_headers {
write!(out, "{} {} {}\r\n", resp.version, resp.status, resp.reason)?;
for (k, v) in &resp.headers {
write!(out, "{k}: {v}\r\n")?;
}
out.write_all(b"\r\n")?;
}
out.write_all(&resp.body)?;
Ok(())
}
fn remote_name_from_url(url: &Url) -> Result<String, String> {
let path = url.path.as_str();
let path_no_query = match path.find('?') {
Some(i) => &path[..i],
None => path,
};
let trimmed = path_no_query.trim_end_matches('/');
let last = trimmed.rsplit('/').next().unwrap_or("");
if last.is_empty() {
return Err("Refusing to overwrite stdin".to_string());
}
let basename = Path::new(last)
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| "Refusing to overwrite stdin".to_string())?;
if basename.is_empty() {
return Err("Refusing to overwrite stdin".to_string());
}
Ok(basename.to_string())
}
fn print_usage() {
println!(
"rsurl {VERSION} — a pure-Rust curl
Usage: rsurl [options] <url>...
Options:
-o, --output <file> write body to file instead of stdout
-O, --remote-name save body as the URL's last path segment
-i, --include include response headers in the output
-I, --head issue HEAD instead of GET
-v, --verbose print request/response headers to stderr
-s, --silent suppress error messages
-X, --request <method> override HTTP method
-H, --header <line> add a request header (repeatable)
-d, --data <body> send body and switch method to POST
-A, --user-agent <ua> set User-Agent
-e, --referer <ref> set Referer
-L, --location follow 3xx redirects
--max-redirs <n> cap on redirect hops (default 50)
-u, --user <user:pass> HTTP Basic auth credentials
-k, --insecure don't verify the TLS certificate chain
--cacert <file> PEM bundle to use instead of system trust
--max-time <secs> cap on the whole operation's wall time
--connect-timeout <secs>
cap on the TCP connect step
--http2 require HTTP/2 (ALPN h2); error if unavailable
--http1.1 force HTTP/1.1 (alias: --http1)
-h, --help print this help
-V, --version print version
"
);
}