//! HTTP transfers through WASI, without access to host-injected credentials. use base64::{Engine, engine::general_purpose::STANDARD}; use clap::Parser; use std::{ io::{Read, Write}, time::{Duration, Instant}, }; use wasi::http::{outgoing_handler, types::*}; #[derive(Parser, Debug)] #[command(disable_version_flag = true)] pub struct Args { pub url: String, #[arg(skip)] pub parts: Vec<(String, u8)>, #[arg(short = 'X', long = "request")] pub method: Option, #[arg(short = 'H', long = "header", allow_hyphen_values = true)] pub headers: Vec, #[arg(short = 'd', long = "data", allow_hyphen_values = true)] pub data: Vec, #[arg(long = "data-raw", allow_hyphen_values = true)] pub raw: Vec, #[arg(long = "data-binary", allow_hyphen_values = true)] pub binary: Vec, #[arg(long, allow_hyphen_values = true)] pub json: Vec, #[arg(short = 'o', long = "output")] pub output: Option, #[arg(short = 'O', long = "remote-name")] pub remote_name: bool, #[arg(short = 's', long = "silent")] pub silent: bool, #[arg(short = 'S', long = "show-error")] pub show_error: bool, #[arg(short = 'i', long = "include")] pub include: bool, #[arg(short = 'I', long = "head")] pub head: bool, #[arg(short = 'L', long = "location")] pub location: bool, #[arg(long, default_value_t = 50, allow_hyphen_values = true)] pub max_redirs: i64, #[arg(short = 'f', long = "fail", conflicts_with = "fail_with_body")] pub fail: bool, #[arg(long)] pub fail_with_body: bool, #[arg(short = 'w', long = "write-out")] pub write_out: Option, #[arg(short = 'v', long = "verbose")] pub verbose: bool, #[arg(short = 'u', long = "user")] pub user: Option, #[arg(short = 'A', long = "user-agent")] pub agent: Option, #[arg(short = 'k', long = "insecure")] pub insecure: bool, #[arg(short = 'm', long = "max-time")] pub max_time: Option, } pub struct Error { pub code: i32, pub message: String, } type Result = std::result::Result; fn error(code: i32, message: impl Into) -> Error { Error { code, message: message.into(), } } fn file_data(text: &str, files: bool, strip: bool) -> Result> { let mut bytes = if files && text.starts_with('@') { if text == "@-" { let mut bytes = Vec::new(); std::io::stdin() .read_to_end(&mut bytes) .map_err(|e| error(26, e.to_string()))?; bytes } else { std::fs::read(&text[1..]).map_err(|e| error(26, e.to_string()))? } } else { return Ok(text.as_bytes().to_vec()); }; if strip { bytes.retain(|b| !matches!(b, b'\r' | b'\n' | 0)); } Ok(bytes) } fn wait(pollable: wasi::io::poll::Pollable, deadline: Option) -> Result<()> { if let Some(deadline) = deadline { let remaining = deadline .checked_duration_since(Instant::now()) .ok_or_else(|| error(28, "Operation timed out"))?; let timer = wasi::clocks::monotonic_clock::subscribe_duration( remaining.as_nanos().min(u64::MAX as u128) as u64, ); wasi::io::poll::poll(&[&pollable, &timer]); if Instant::now() >= deadline { return Err(error(28, "Operation timed out")); } } else { pollable.block(); } Ok(()) } fn transport_error(e: ErrorCode) -> Error { let code = match e { ErrorCode::DnsError(_) | ErrorCode::DestinationNotFound => 6, ErrorCode::ConnectionTimeout | ErrorCode::HttpResponseTimeout => 28, ErrorCode::ConnectionRefused | ErrorCode::ConnectionTerminated => 7, _ => 56, }; error(code, format!("HTTP transfer failed: {e:?}")) } struct Response { status: u16, headers: Vec<(String, Vec)>, body: Vec, } fn send( url: &url::Url, method: &str, headers: &[(String, Vec)], data: Option<&[u8]>, deadline: Option, ) -> Result { let fields = Fields::new(); for (name, value) in headers { fields .append(name, value) .map_err(|_| error(2, "Invalid HTTP header"))?; } // Frame even empty requests so Hyper does not discard an unknown-length body. fields .set( "content-length", &[data.map_or(0, <[u8]>::len).to_string().into_bytes()], ) .map_err(|_| error(2, "Invalid content length"))?; let request = OutgoingRequest::new(fields); let method = match method { "GET" => Method::Get, "POST" => Method::Post, "HEAD" => Method::Head, "PUT" => Method::Put, "DELETE" => Method::Delete, "PATCH" => Method::Patch, "OPTIONS" => Method::Options, other => Method::Other(other.into()), }; request .set_method(&method) .map_err(|_| error(2, "Invalid HTTP method"))?; request .set_scheme(Some(&if url.scheme() == "https" { Scheme::Https } else { Scheme::Http })) .map_err(|_| error(3, "Invalid scheme"))?; request .set_authority(Some( &url[url::Position::BeforeHost..url::Position::AfterPort], )) .map_err(|_| error(3, "Invalid authority"))?; request .set_path_with_query(Some( &url[url::Position::BeforePath..url::Position::AfterQuery], )) .map_err(|_| error(3, "Invalid path"))?; let outgoing = request .body() .map_err(|_| error(26, "Cannot open request body"))?; let options = RequestOptions::new(); if let Some(deadline) = deadline { let ns = deadline .saturating_duration_since(Instant::now()) .as_nanos() .min(u64::MAX as u128) as u64; options .set_connect_timeout(Some(ns)) .map_err(|_| error(28, "Timeout unsupported"))?; options .set_first_byte_timeout(Some(ns)) .map_err(|_| error(28, "Timeout unsupported"))?; options .set_between_bytes_timeout(Some(ns)) .map_err(|_| error(28, "Timeout unsupported"))?; } let future = outgoing_handler::handle(request, Some(options)).map_err(transport_error)?; if let Some(data) = data { let stream = outgoing .write() .map_err(|_| error(26, "Cannot write request"))?; let mut remaining = data; while !remaining.is_empty() { wait(stream.subscribe(), deadline)?; let count = stream .check_write() .map_err(|_| error(55, "Request write failed"))? as usize; let count = count.min(remaining.len()); stream .write(&remaining[..count]) .map_err(|_| error(55, "Request write failed"))?; remaining = &remaining[count..]; } // After the last byte, the peer can already answer and close its reader. // Do not flush or poll that reader after the framed body is complete. drop(stream); } OutgoingBody::finish(outgoing, None).map_err(transport_error)?; wait(future.subscribe(), deadline)?; let response = future .get() .ok_or_else(|| error(56, "Missing response"))? .map_err(|_| error(56, "Consumed response"))? .map_err(transport_error)?; let status = response.status(); let headers = response.headers().entries(); let incoming = response .consume() .map_err(|_| error(56, "Cannot consume response"))?; let stream = incoming .stream() .map_err(|_| error(56, "Cannot read response"))?; let mut body = Vec::new(); loop { wait(stream.subscribe(), deadline)?; match stream.read(65536) { Ok(bytes) => body.extend(bytes), Err(wasi::io::streams::StreamError::Closed) => break, Err(_) => return Err(error(56, "Response read failed")), } } drop(stream); let trailers = IncomingBody::finish(incoming); wait(trailers.subscribe(), deadline)?; trailers .get() .ok_or_else(|| error(56, "Missing trailers"))? .map_err(|_| error(56, "Consumed trailers"))? .map_err(transport_error)?; Ok(Response { status, headers, body, }) } fn header_block(response: &Response) -> Vec { let reason = match response.status { 200 => "OK", 201 => "Created", 204 => "No Content", 301 => "Moved Permanently", 302 => "Found", 303 => "See Other", 307 => "Temporary Redirect", 308 => "Permanent Redirect", 400 => "Bad Request", 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found", 500 => "Internal Server Error", _ => "", }; let mut bytes = format!("HTTP/1.1 {} {reason}\r\n", response.status).into_bytes(); for (name, value) in &response.headers { bytes.extend(name.as_bytes()); bytes.extend(b": "); bytes.extend(value); bytes.extend(b"\r\n"); } bytes.extend(b"\r\n"); bytes } pub fn run(args: &Args) -> Result<()> { let mut url = url::Url::parse(&args.url) .or_else(|_| url::Url::parse(&format!("http://{}", args.url))) .map_err(|_| error(3, "URL rejected"))?; if !matches!(url.scheme(), "http" | "https") { return Err(error(1, "Protocol not supported")); } let deadline = match args.max_time { Some(n) if !n.is_finite() || n < 0.0 => return Err(error(2, "Invalid max-time")), Some(n) if n > 0.0 => Some( Instant::now() .checked_add( Duration::try_from_secs_f64(n).map_err(|_| error(2, "Invalid max-time"))?, ) .ok_or_else(|| error(2, "Invalid max-time"))?, ), _ => None, }; let mut data = Vec::new(); let has_data = !args.data.is_empty() || !args.raw.is_empty() || !args.binary.is_empty() || !args.json.is_empty(); for (value, kind) in &args.parts { if !data.is_empty() && args.json.is_empty() { data.push(b'&'); } data.extend(file_data(value, *kind != 1, *kind == 0)?); } let mut send_data = has_data; let mut method = if args.head { "HEAD" } else if has_data { "POST" } else { "GET" } .to_string(); if let Some(custom) = &args.method { method.clone_from(custom); } let mut headers: Vec<(String, Vec)> = vec![ ( "User-Agent".into(), args.agent .as_deref() .unwrap_or("curl/8.0.0") .as_bytes() .to_vec(), ), ( "Accept".into(), if args.json.is_empty() { b"*/*".to_vec() } else { b"application/json".to_vec() }, ), ]; if has_data { headers.push(( "Content-Type".into(), if args.json.is_empty() { b"application/x-www-form-urlencoded".to_vec() } else { b"application/json".to_vec() }, )); } let user = args.user.clone().or_else(|| { if url.username().is_empty() { None } else { Some(format!( "{}:{}", url.username(), url.password().unwrap_or("") )) } }); if let Some(user) = user { let user = if user.contains(':') { user } else { format!("{user}:") }; headers.push(( "Authorization".into(), format!("Basic {}", STANDARD.encode(user)).into_bytes(), )); } url.set_username("").map_err(|_| error(3, "Invalid URL"))?; url.set_password(None) .map_err(|_| error(3, "Invalid URL"))?; let mut custom_headers = std::collections::HashSet::new(); for h in &args.headers { let (name, value) = h .split_once(':') .or_else(|| h.strip_suffix(';').map(|name| (name, ";"))) .ok_or_else(|| error(2, "Invalid HTTP header"))?; if custom_headers.insert(name.to_ascii_lowercase()) { headers.retain(|(n, _)| !n.eq_ignore_ascii_case(name)); } if !value.is_empty() { headers.push(( name.into(), if value == ";" { Vec::new() } else { value.trim_start().as_bytes().to_vec() }, )); } } let output = args.output.clone().or_else(|| { if args.remote_name { url.path_segments() .and_then(|mut s| s.next_back()) .filter(|s| !s.is_empty()) .map(str::to_owned) } else { None } }); if args.remote_name && output.is_none() { return Err(error(23, "Remote file name has no length")); } let mut bytes = Vec::new(); let mut hops = 0; let response = loop { if args.verbose { eprintln!( "> {method} {} HTTP/1.1", &url[url::Position::BeforePath..url::Position::AfterQuery] ); for (name, value) in &headers { eprintln!("> {name}: {}", String::from_utf8_lossy(value)); } } let response = send( &url, &method, &headers, if send_data { Some(&data) } else { None }, deadline, )?; if args.verbose { for line in String::from_utf8_lossy(&header_block(&response)).lines() { eprintln!("< {line}"); } } if args.include || args.head { bytes.extend(header_block(&response)); } let location = response .headers .iter() .find(|(n, _)| n.eq_ignore_ascii_case("location")); if args.location && matches!(response.status, 301 | 302 | 303 | 307 | 308) && let Some((_, location)) = location { if args.max_redirs >= 0 && hops >= args.max_redirs { return Err(error( 47, format!("Maximum ({}) redirects followed", args.max_redirs), )); } hops += 1; let next = url .join(&String::from_utf8_lossy(location)) .map_err(|_| error(3, "Invalid redirect URL"))?; if !matches!(next.scheme(), "http" | "https") { return Err(error(1, "Redirect protocol not supported")); } if next.origin() != url.origin() { headers.retain(|(n, _)| { !n.eq_ignore_ascii_case("authorization") && !n.eq_ignore_ascii_case("cookie") }); } if response.status == 303 && method != "HEAD" || matches!(response.status, 301 | 302) && method == "POST" { send_data = false; method = args.method.clone().unwrap_or_else(|| "GET".into()); headers.retain(|(n, _)| { !n.eq_ignore_ascii_case("content-type") && !n.eq_ignore_ascii_case("content-length") }); } url = next; continue; } break response; }; let failed = (args.fail || args.fail_with_body) && response.status >= 400; if !(args.head || args.fail && failed) { bytes.extend(&response.body); } if let Some(path) = output.filter(|p| p != "-") { if !(args.fail && failed && bytes.is_empty()) { std::fs::write(path, &bytes).map_err(|e| error(23, e.to_string()))?; } } else { std::io::stdout() .write_all(&bytes) .map_err(|e| error(23, e.to_string()))?; } if let Some(format) = &args.write_out { let content_type = response .headers .iter() .find(|(n, _)| n.eq_ignore_ascii_case("content-type")) .map(|(_, v)| String::from_utf8_lossy(v).into_owned()) .unwrap_or_default(); let format = format .replace("%{http_code}", &response.status.to_string()) .replace("%{url_effective}", url.as_str()) .replace("%{content_type}", &content_type) .replace( "%{size_download}", &if args.fail && failed { 0 } else { response.body.len() } .to_string(), ) .replace("\\n", "\n"); print!("{format}"); } if failed { return Err(error( 22, format!("The requested URL returned error: {}", response.status), )); } Ok(()) }