use anyhow::{Context, Result, anyhow, bail};
use http_body_util::{BodyExt, Empty, Limited};
use hyper::{Request, body::Bytes, body::Incoming};
use hyper_util::rt::TokioIo;
use std::{path::Path, sync::Arc, time::Duration};
use tokio::net::TcpStream;
use tokio_rustls::{
TlsConnector,
rustls::{ClientConfig, RootCertStore, crypto::ring::default_provider, pki_types::ServerName},
};
#[derive(Debug)]
pub struct HttpsResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl HttpsResponse {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(header_name, _)| header_name == name)
.map(|(_, value)| value.as_str())
}
}
#[derive(Debug, PartialEq, Eq)]
struct UrlParts {
host: String,
port: u16,
path_and_query: String,
}
fn split_https_url(url: &str) -> Result<UrlParts> {
let uri: hyper::Uri = url
.parse()
.with_context(|| format!("URL inválida: {url}"))?;
if uri.scheme_str() != Some("https") {
bail!("só https é aceito: {url}");
}
let host = uri
.host()
.ok_or_else(|| anyhow!("URL sem host: {url}"))?
.to_string();
Ok(UrlParts {
host,
port: uri.port_u16().unwrap_or(443),
path_and_query: uri
.path_and_query()
.map(|path| path.as_str().to_string())
.unwrap_or_else(|| "/".into()),
})
}
fn tls_connector() -> Result<TlsConnector> {
let roots = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
};
let config = ClientConfig::builder_with_provider(Arc::new(default_provider()))
.with_safe_default_protocol_versions()
.context("versões de TLS indisponíveis")?
.with_root_certificates(roots)
.with_no_client_auth();
Ok(TlsConnector::from(Arc::new(config)))
}
pub async fn get(
url: &str,
headers: &[(&str, &str)],
timeout: Duration,
max_body_bytes: usize,
) -> Result<HttpsResponse> {
tokio::time::timeout(timeout, get_inner(url, headers, max_body_bytes))
.await
.map_err(|_| anyhow!("tempo esgotado ao consultar {url}"))?
}
async fn get_inner(
url: &str,
headers: &[(&str, &str)],
max_body_bytes: usize,
) -> Result<HttpsResponse> {
let (status, response_headers, body) = open(url, headers).await?;
let body = Limited::new(body, max_body_bytes)
.collect()
.await
.map_err(|error| anyhow!("resposta grande demais ou interrompida: {error}"))?
.to_bytes()
.to_vec();
Ok(HttpsResponse {
status,
headers: response_headers,
body,
})
}
async fn open(
url: &str,
headers: &[(&str, &str)],
) -> Result<(u16, Vec<(String, String)>, Incoming)> {
let parts = split_https_url(url)?;
let tcp = TcpStream::connect((parts.host.as_str(), parts.port))
.await
.with_context(|| format!("sem conexão com {}:{}", parts.host, parts.port))?;
let server_name = ServerName::try_from(parts.host.clone())
.map_err(|_| anyhow!("nome de servidor inválido: {}", parts.host))?;
let tls = tls_connector()?
.connect(server_name, tcp)
.await
.with_context(|| format!("falha no TLS com {}", parts.host))?;
let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(tls))
.await
.context("falha no handshake HTTP")?;
tokio::spawn(async move {
let _ = connection.await;
});
let mut request = Request::builder()
.method("GET")
.uri(&parts.path_and_query)
.header("host", &parts.host)
.header("connection", "close");
for (name, value) in headers {
request = request.header(*name, *value);
}
let request = request
.body(Empty::<Bytes>::new())
.context("requisição inválida")?;
let response = sender
.send_request(request)
.await
.context("falha ao enviar a requisição")?;
let status = response.status().as_u16();
let response_headers = response
.headers()
.iter()
.filter_map(|(name, value)| {
value
.to_str()
.ok()
.map(|value| (name.as_str().to_string(), value.to_string()))
})
.collect();
Ok((status, response_headers, response.into_body()))
}
const MAX_REDIRECTS: usize = 5;
fn host_allowed(host: &str, allowed: &[&str]) -> bool {
allowed
.iter()
.any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}")))
}
fn redirect_target(status: u16, headers: &[(String, String)]) -> Result<Option<String>> {
if !matches!(status, 301 | 302 | 303 | 307 | 308) {
return Ok(None);
}
let location = headers
.iter()
.find(|(name, _)| name == "location")
.map(|(_, value)| value.as_str())
.ok_or_else(|| anyhow!("redirecionamento (HTTP {status}) sem Location"))?;
if !location.starts_with("https://") {
bail!("redirecionamento para um destino que não é https: {location}");
}
Ok(Some(location.to_string()))
}
pub async fn download(
url: &str,
headers: &[(&str, &str)],
destination: &Path,
max_bytes: u64,
timeout: Duration,
allowed_hosts: &[&str],
) -> Result<()> {
let mut partial = destination.as_os_str().to_owned();
partial.push(".partial");
let partial = std::path::PathBuf::from(partial);
let result = tokio::time::timeout(
timeout,
download_inner(url, headers, &partial, max_bytes, allowed_hosts),
)
.await
.map_err(|_| anyhow!("tempo esgotado ao baixar {url}"))
.and_then(|result| result);
match result {
Ok(()) => tokio::fs::rename(&partial, destination)
.await
.with_context(|| format!("não consegui gravar {}", destination.display())),
Err(error) => {
let _ = tokio::fs::remove_file(&partial).await;
Err(error)
}
}
}
async fn download_inner(
url: &str,
headers: &[(&str, &str)],
partial: &Path,
max_bytes: u64,
allowed_hosts: &[&str],
) -> Result<()> {
use tokio::io::AsyncWriteExt;
let mut current = url.to_string();
for _ in 0..=MAX_REDIRECTS {
let host = split_https_url(¤t)?.host;
if !host_allowed(&host, allowed_hosts) {
bail!("download recusado: {host} não está na lista de origens permitidas");
}
let (status, response_headers, mut body) = open(¤t, headers).await?;
if let Some(next) = redirect_target(status, &response_headers)? {
current = next;
continue;
}
if status != 200 {
bail!("o servidor respondeu HTTP {status} em {host}");
}
let mut file = tokio::fs::File::create(partial)
.await
.with_context(|| format!("não consegui criar {}", partial.display()))?;
let mut written: u64 = 0;
while let Some(frame) = body.frame().await {
let frame = frame.map_err(|error| anyhow!("download interrompido: {error}"))?;
if let Ok(data) = frame.into_data() {
written += data.len() as u64;
if written > max_bytes {
bail!("o arquivo passa do limite de {max_bytes} bytes");
}
file.write_all(&data)
.await
.context("falha ao gravar o download")?;
}
}
file.flush().await.context("falha ao gravar o download")?;
return Ok(());
}
bail!("redirecionamentos demais (mais de {MAX_REDIRECTS})")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn splits_a_plain_url() {
assert_eq!(
split_https_url("https://api.github.com/repos/a/b/releases/latest").unwrap(),
UrlParts {
host: "api.github.com".into(),
port: 443,
path_and_query: "/repos/a/b/releases/latest".into(),
}
);
}
#[test]
fn keeps_port_and_query() {
let parts = split_https_url("https://example.com:8443/x?y=1").unwrap();
assert_eq!(parts.port, 8443);
assert_eq!(parts.path_and_query, "/x?y=1");
}
#[test]
fn bare_host_gets_root_path() {
assert_eq!(
split_https_url("https://example.com")
.unwrap()
.path_and_query,
"/"
);
}
#[test]
fn refuses_plain_http_and_garbage() {
assert!(split_https_url("http://example.com/").is_err());
assert!(split_https_url("ftp://example.com/").is_err());
assert!(split_https_url("não é url").is_err());
}
#[test]
fn allowed_hosts_match_exactly_or_as_subdomains_only() {
let allowed = ["github.com", "githubusercontent.com"];
for host in [
"github.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
] {
assert!(host_allowed(host, &allowed), "{host}");
}
for host in [
"evilgithub.com",
"github.com.evil.com",
"githubusercontent.com.evil.org",
"example.com",
"",
] {
assert!(!host_allowed(host, &allowed), "{host}");
}
}
fn headers(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(name, value)| (name.to_string(), value.to_string()))
.collect()
}
#[test]
fn redirects_are_followed_only_to_absolute_https() {
let location = headers(&[("location", "https://objects.githubusercontent.com/x")]);
for status in [301, 302, 303, 307, 308] {
assert_eq!(
redirect_target(status, &location).unwrap().as_deref(),
Some("https://objects.githubusercontent.com/x")
);
}
assert_eq!(redirect_target(200, &location).unwrap(), None);
assert!(redirect_target(302, &headers(&[])).is_err());
assert!(redirect_target(302, &headers(&[("location", "http://example.com/x")])).is_err());
assert!(redirect_target(302, &headers(&[("location", "/relativo")])).is_err());
}
#[tokio::test]
async fn download_refuses_a_host_outside_the_list_before_connecting() {
let directory = std::env::temp_dir().join(format!("rp-dl-{}", std::process::id()));
std::fs::create_dir_all(&directory).unwrap();
let destination = directory.join("a.deb");
let error = download(
"https://example.invalid/a.deb",
&[],
&destination,
1024,
Duration::from_secs(5),
&["github.com"],
)
.await
.unwrap_err();
assert!(error.to_string().contains("não está na lista"), "{error}");
assert!(!destination.exists());
assert!(!directory.join("a.deb.partial").exists());
let _ = std::fs::remove_dir_all(&directory);
}
#[test]
fn tls_connector_builds_with_embedded_roots() {
assert!(tls_connector().is_ok());
}
#[tokio::test]
async fn closed_port_is_a_clear_error() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let error = get(
&format!("https://127.0.0.1:{port}/"),
&[],
Duration::from_secs(5),
1024,
)
.await
.unwrap_err();
assert!(error.to_string().contains("sem conexão"), "{error}");
}
}