517 lines
17 KiB
Rust
517 lines
17 KiB
Rust
//! 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<String>,
|
|
#[arg(short = 'H', long = "header", allow_hyphen_values = true)]
|
|
pub headers: Vec<String>,
|
|
#[arg(short = 'd', long = "data", allow_hyphen_values = true)]
|
|
pub data: Vec<String>,
|
|
#[arg(long = "data-raw", allow_hyphen_values = true)]
|
|
pub raw: Vec<String>,
|
|
#[arg(long = "data-binary", allow_hyphen_values = true)]
|
|
pub binary: Vec<String>,
|
|
#[arg(long, allow_hyphen_values = true)]
|
|
pub json: Vec<String>,
|
|
#[arg(short = 'o', long = "output")]
|
|
pub output: Option<String>,
|
|
#[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<String>,
|
|
#[arg(short = 'v', long = "verbose")]
|
|
pub verbose: bool,
|
|
#[arg(short = 'u', long = "user")]
|
|
pub user: Option<String>,
|
|
#[arg(short = 'A', long = "user-agent")]
|
|
pub agent: Option<String>,
|
|
#[arg(short = 'k', long = "insecure")]
|
|
pub insecure: bool,
|
|
#[arg(short = 'm', long = "max-time")]
|
|
pub max_time: Option<f64>,
|
|
}
|
|
pub struct Error {
|
|
pub code: i32,
|
|
pub message: String,
|
|
}
|
|
type Result<T> = std::result::Result<T, Error>;
|
|
fn error(code: i32, message: impl Into<String>) -> Error {
|
|
Error {
|
|
code,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
fn file_data(text: &str, files: bool, strip: bool) -> Result<Vec<u8>> {
|
|
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<Instant>) -> 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<u8>)>,
|
|
body: Vec<u8>,
|
|
}
|
|
fn send(
|
|
url: &url::Url,
|
|
method: &str,
|
|
headers: &[(String, Vec<u8>)],
|
|
data: Option<&[u8]>,
|
|
deadline: Option<Instant>,
|
|
) -> Result<Response> {
|
|
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<u8> {
|
|
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<u8>)> = 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(())
|
|
}
|