use core::fmt;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt, BufReader};
use crate::terminal_safe;
mod chunked;
#[cfg(feature = "otel")]
mod post;
#[cfg(feature = "otel")]
pub use post::post_json;
mod asset;
pub use asset::{RedirectPolicy, get_asset};
mod transport;
pub(crate) use transport::connect_and_send;
pub const MAX_HEADER_BYTES: usize = 64 * 1024;
#[derive(Clone, PartialEq, Eq)]
pub struct Target {
pub https: bool,
pub host: String,
pub port: u16,
pub path: String,
}
impl Target {
const fn default_port(https: bool) -> u16 {
if https { 443 } else { 80 }
}
}
impl fmt::Debug for Target {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"Target {{ https: {}, host: {:?}, port: {}, path: <redacted> }}",
self.https, self.host, self.port
)
}
}
#[derive(Debug)]
pub enum FetchError {
Url(String),
Transport(std::io::Error),
Status(u16),
Redirect {
location: String,
},
TransferEncoding,
TooLarge {
limit: usize,
},
HeadersTooLarge {
limit: usize,
},
Timeout,
Truncated {
expected: usize,
got: usize,
},
}
impl fmt::Display for FetchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Url(reason) => write!(f, "not a fetchable url: {reason}"),
Self::Transport(source) => write!(f, "fetch failed: {source}"),
Self::Status(code) => write!(f, "fetch answered {code}"),
Self::Redirect { location } => write!(f, "fetch was redirected to {location}"),
Self::TransferEncoding => {
write!(
f,
"fetch response used transfer-encoding, which this client refuses to decode"
)
}
Self::TooLarge { limit } => write!(f, "fetch response exceeded the {limit}-byte limit"),
Self::HeadersTooLarge { limit } => {
write!(f, "fetch response headers exceeded the {limit}-byte limit")
}
Self::Timeout => write!(f, "fetch timed out"),
Self::Truncated { expected, got } => {
write!(
f,
"fetch response was truncated: expected {expected} bytes, got {got}"
)
}
}
}
}
impl core::error::Error for FetchError {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Self::Transport(source) => Some(source),
Self::Url(_)
| Self::Status(_)
| Self::Redirect { .. }
| Self::TransferEncoding
| Self::TooLarge { .. }
| Self::HeadersTooLarge { .. }
| Self::Timeout
| Self::Truncated { .. } => None,
}
}
}
impl From<std::io::Error> for FetchError {
fn from(source: std::io::Error) -> Self {
Self::Transport(source)
}
}
pub const CREDENTIALS_REFUSAL: &str = concat!(
"credentials before the host (`user@` or `user:pass@`) are not supported; ",
"the url is not echoed, since it carries one"
);
pub fn url_for_message(url: &str) -> &str {
if url.contains('@') {
"a url that may carry credentials"
} else {
url
}
}
fn authority_of(rest: &str) -> &str {
match rest.find(['/', '?', '#']) {
Some(i) => &rest[..i],
None => rest,
}
}
pub fn url_carries_credentials(url: &str) -> bool {
let rest = url.split_once("://").map_or(url, |(_scheme, rest)| rest);
let rest = rest.strip_prefix("//").unwrap_or(rest);
authority_of(rest).contains('@')
}
pub fn parse_url(url: &str) -> Result<Target, FetchError> {
if url_carries_credentials(url) {
return Err(FetchError::Url(CREDENTIALS_REFUSAL.to_owned()));
}
let (https, rest) = match url.strip_prefix("https://") {
Some(rest) => (true, rest),
None => match url.strip_prefix("http://") {
Some(rest) => (false, rest),
None => {
return Err(FetchError::Url(format!(
"{} does not start with http:// or https://",
url_for_message(url)
)));
}
},
};
let authority = authority_of(rest);
let remainder = &rest[authority.len()..];
let remainder = remainder.split('#').next().unwrap_or(remainder);
let path = match remainder.chars().next() {
Some('/') => remainder.to_owned(),
Some(_) => format!("/{remainder}"),
None => "/".to_owned(),
};
let (host, port) = match authority.rsplit_once(':') {
Some((h, p)) => (
h,
p.parse().map_err(|_err| {
FetchError::Url(format!("{} has a non-numeric port", url_for_message(url)))
})?,
),
None => (authority, Target::default_port(https)),
};
if host.is_empty() {
return Err(FetchError::Url(format!(
"{} has no host",
url_for_message(url)
)));
}
Ok(Target {
https,
host: host.to_string(),
port,
path,
})
}
pub fn root_store() -> &'static tokio_rustls::rustls::RootCertStore {
static ROOTS: std::sync::LazyLock<tokio_rustls::rustls::RootCertStore> =
std::sync::LazyLock::new(|| {
tokio_rustls::rustls::RootCertStore::from_iter(
webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
)
});
&ROOTS
}
pub fn tls_connector() -> &'static tokio_rustls::TlsConnector {
static CONNECTOR: std::sync::LazyLock<tokio_rustls::TlsConnector> =
std::sync::LazyLock::new(|| {
let provider = Arc::new(tokio_rustls::rustls::crypto::ring::default_provider());
let config = tokio_rustls::rustls::ClientConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.expect("ring's default cipher suites cover rustls's own default protocol versions")
.with_root_certificates(root_store().clone())
.with_no_client_auth();
tokio_rustls::TlsConnector::from(Arc::new(config))
});
&CONNECTOR
}
pub async fn get(target: &Target, limit: usize, timeout: Duration) -> Result<Vec<u8>, FetchError> {
match tokio::time::timeout(timeout, get_inner(target, limit)).await {
Ok(result) => result,
Err(_elapsed) => Err(FetchError::Timeout),
}
}
async fn get_inner(target: &Target, limit: usize) -> Result<Vec<u8>, FetchError> {
let stream = connect_and_send(target, build_get_request(target).as_bytes()).await?;
read_response(stream, limit).await
}
const USER_AGENT: &str = concat!("shep/", env!("CARGO_PKG_VERSION"), " (https://shep-pm.com)");
fn build_get_request(target: &Target) -> String {
let default_port = Target::default_port(target.https);
let host = if target.port == default_port {
target.host.clone()
} else {
format!("{}:{}", target.host, target.port)
};
format!(
"GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: {USER_AGENT}\r\nConnection: close\r\n\r\n",
path = target.path,
)
}
async fn read_response<S: AsyncRead + Unpin>(
stream: S,
limit: usize,
) -> Result<Vec<u8>, FetchError> {
let mut headers = BufReader::new(stream).take(MAX_HEADER_BYTES as u64);
let mut status_line = String::new();
headers.read_line(&mut status_line).await?;
let code = parse_status_line(&status_line)?;
let mut location: Option<String> = None;
let mut transfer_encodings: Vec<String> = Vec::new();
let mut content_length: Option<u64> = None;
let mut content_length_ok = true;
loop {
let mut line = String::new();
let read = headers.read_line(&mut line).await?;
if read == 0 {
if headers.limit() == 0 {
return Err(FetchError::HeadersTooLarge {
limit: MAX_HEADER_BYTES,
});
}
return Err(FetchError::Transport(std::io::Error::other(
"response headers ended without a blank line",
)));
}
if line == "\r\n" || line == "\n" {
break;
}
let Some((name, value)) = line.split_once(':') else {
return Err(FetchError::Transport(std::io::Error::other(
"malformed header line: no colon",
)));
};
let value = value.trim();
match name.trim().to_ascii_lowercase().as_str() {
"location" => location = Some(terminal_safe::sanitise(value).0),
"transfer-encoding" => transfer_encodings.push(value.to_owned()),
"content-length" => match value.parse::<u64>() {
Ok(parsed) => match content_length {
Some(existing) if existing != parsed => content_length_ok = false,
Some(_) => {}
None => content_length = Some(parsed),
},
Err(_err) => content_length_ok = false,
},
_ => {}
}
}
if (300..400).contains(&code)
&& let Some(location) = location
{
return Err(FetchError::Redirect { location });
}
if !(200..300).contains(&code) {
return Err(FetchError::Status(code));
}
match transfer_encodings.as_slice() {
[] => {}
[coding] if coding.eq_ignore_ascii_case("chunked") => {
return chunked::read_body(headers.into_inner(), limit).await;
}
_ => return Err(FetchError::TransferEncoding),
}
if !content_length_ok {
return Err(FetchError::Transport(std::io::Error::other(
"content-length header was not a number, or two content-length headers disagreed",
)));
}
let Some(content_length) = content_length else {
return Err(FetchError::Transport(std::io::Error::other(
"response carried no content-length",
)));
};
if content_length > limit as u64 {
return Err(FetchError::TooLarge { limit });
}
let expected = content_length as usize;
let mut reader = headers.into_inner();
let mut body = vec![0u8; expected];
let mut filled = 0;
while filled < expected {
let read = reader.read(&mut body[filled..]).await?;
if read == 0 {
return Err(FetchError::Truncated {
expected,
got: filled,
});
}
filled += read;
if filled > limit {
return Err(FetchError::TooLarge { limit });
}
}
Ok(body)
}
pub(crate) fn parse_status_line(status_line: &str) -> Result<u16, std::io::Error> {
let mut parts = status_line.split_whitespace();
match parts.next() {
Some(version) if version.starts_with("HTTP/") => {}
_ => {
return Err(std::io::Error::other(
"response did not start with an HTTP status line",
));
}
}
parts
.next()
.and_then(|code| code.parse().ok())
.ok_or_else(|| std::io::Error::other("malformed http status code"))
}
#[cfg(test)]
mod tests;