Files
sausage/userland/curl/src/lib.rs
T

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(())
}