use std::{
ffi::{OsStr, OsString},
io::Write,
path::{Path, PathBuf},
sync::Mutex,
time::Duration,
};
use base64_stream::FromBase64Writer;
use bytes::Bytes;
use reqwest::{Client, Url};
use tokio::io::AsyncWriteExt;
use crate::{
credentials,
errors::{DownloadError, SourceFile},
PartialConfig,
};
enum Response<'a> {
Real(
reqwest::Response,
#[allow(unused)] tokio::sync::SemaphorePermit<'a>,
),
#[cfg(test)]
Mock(Option<Bytes>),
}
impl Response<'_> {
fn has_header(&self, name: &str) -> bool {
match self {
Response::Real(response, _) => response.headers().contains_key(name),
#[cfg(test)]
Response::Mock(_) => false,
}
}
async fn chunk(&mut self) -> Result<Option<Bytes>, DownloadError> {
match self {
Response::Real(res, _) => {
res.chunk()
.await
.map_err(|error| DownloadError::FailedToReadDownload {
url: Box::new(res.url().clone()),
error,
})
}
#[cfg(test)]
Response::Mock(data) => Ok(data.take()),
}
}
}
pub struct Network {
client: Client,
connection_semaphore: tokio::sync::Semaphore,
source_file_cache: Mutex<std::collections::HashMap<Url, SourceFile>>,
use_git_credentials: bool,
#[cfg(test)]
mock_network: Option<std::collections::HashMap<Url, Bytes>>,
}
const DEFAULT_TIMEOUT_SECS: u64 = 60;
const MAX_CONCURRENT_CONNECTIONS: usize = 40;
const USER_AGENT: &str = concat!(
env!("CARGO_PKG_NAME"),
"/",
env!("CARGO_PKG_VERSION"),
" (",
env!("CARGO_PKG_HOMEPAGE"),
")"
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PayloadEncoding {
Plaintext,
Base64,
}
impl PayloadEncoding {
fn for_response(response: &Response) -> Self {
if response.has_header("x-gitiles-object-type") {
Self::Base64
} else {
Self::Plaintext
}
}
pub fn to_plaintext<'a, W: Write + 'a>(&self, target: W) -> Box<dyn Write + 'a> {
match self {
Self::Plaintext => Box::new(target),
Self::Base64 => Box::new(FromBase64Writer::new(target)),
}
}
}
impl std::fmt::Display for PayloadEncoding {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Plaintext => write!(f, "plaintext"),
Self::Base64 => write!(f, "base64"),
}
}
}
impl Network {
pub fn acquire(cfg: &PartialConfig) -> Option<Self> {
if cfg.cli.frozen {
None
} else {
let timeout = Duration::from_secs(DEFAULT_TIMEOUT_SECS);
let mut client_builder = Client::builder().user_agent(USER_AGENT).timeout(timeout);
if let Ok(cargo_config) = cargo_config2::Config::load() {
if let Some(cainfo) = cargo_config.http.cainfo {
match Network::parse_ca_file(&cainfo) {
Ok(cert) => client_builder = client_builder.add_root_certificate(cert),
Err(e) => println!(
"failed to load certificate from Cargo http.cainfo `{}`, attempting to download without it. Error: {e:?}", cainfo
),
}
}
}
let client = client_builder
.build()
.expect("Couldn't construct HTTP Client?");
Some(Self {
client,
connection_semaphore: tokio::sync::Semaphore::new(MAX_CONCURRENT_CONNECTIONS),
source_file_cache: Default::default(),
use_git_credentials: cfg.cli.use_git_credentials,
#[cfg(test)]
mock_network: None,
})
}
}
fn parse_ca_file(path: &str) -> Result<reqwest::Certificate, Box<dyn std::error::Error>> {
Ok(reqwest::Certificate::from_pem(&std::fs::read(path)?)?)
}
pub async fn download_and_persist(
&self,
url: Url,
persist_to: &Path,
) -> Result<(), DownloadError> {
let download_tmp_path = PathBuf::from(OsString::from_iter([
persist_to.as_os_str(),
OsStr::new(".part"),
]));
{
let mut res = self.fetch_core(url).await?;
let mut download_tmp =
tokio::fs::File::create(&download_tmp_path)
.await
.map_err(|error| DownloadError::FailedToCreateDownload {
target: download_tmp_path.clone(),
error,
})?;
while let Some(chunk) = res.chunk().await? {
download_tmp.write_all(&chunk[..]).await.map_err(|error| {
DownloadError::FailedToWriteDownload {
target: download_tmp_path.clone(),
error,
}
})?;
}
}
match tokio::fs::rename(&download_tmp_path, &persist_to).await {
Ok(()) => {}
Err(err) => {
let _ = tokio::fs::remove_file(&download_tmp_path).await;
return Err(err).map_err(|error| DownloadError::FailedToFinalizeDownload {
target: persist_to.to_owned(),
error,
})?;
}
}
Ok(())
}
pub async fn download(&self, url: Url) -> Result<Vec<u8>, DownloadError> {
let mut res = self.fetch_core(url).await?;
let encoding = PayloadEncoding::for_response(&res);
let mut output = vec![];
{
let mut writer = encoding.to_plaintext(&mut output);
while let Some(chunk) = res.chunk().await? {
writer
.write_all(&chunk[..])
.map_err(|error| DownloadError::InvalidEncoding { encoding, error })?;
}
writer
.flush()
.map_err(|error| DownloadError::InvalidEncoding { encoding, error })?;
}
Ok(output)
}
pub async fn download_source_file_cached(&self, url: Url) -> Result<SourceFile, DownloadError> {
if let Some(source_file) = self.source_file_cache.lock().unwrap().get(&url) {
return Ok(source_file.clone());
}
let bytes = self.download(url.clone()).await?;
match String::from_utf8(bytes) {
Ok(string) => {
let source_file = SourceFile::new(url.as_str(), string);
self.source_file_cache
.lock()
.unwrap()
.insert(url, source_file.clone());
Ok(source_file)
}
Err(error) => Err(DownloadError::InvalidText {
url: Box::new(url),
error,
}),
}
}
async fn fetch_core(&self, url: Url) -> Result<Response<'_>, DownloadError> {
#[cfg(test)]
if let Some(mock_network) = &self.mock_network {
let chunk = mock_network
.get(&url)
.cloned()
.ok_or_else(|| {
tracing::warn!("Attempt to fetch unsupported URL from mock network: {url}");
DownloadError::FailedToWriteDownload {
target: url.to_string().into(),
error: std::io::Error::other(format!(
"mock network does not support URL: {url}"
)),
}
})?;
return Ok(Response::Mock(Some(chunk)));
}
let permit = self
.connection_semaphore
.acquire()
.await
.expect("Semaphore dropped?!");
let mut request_builder = self.client.get(url.clone());
if self.use_git_credentials {
if let Ok(Some(credential)) = credentials::get_credentials_from_git(&url).await {
request_builder = credential.apply_to_request(request_builder);
}
}
let res = request_builder
.send()
.await
.and_then(|res| res.error_for_status())
.map_err(|error| DownloadError::FailedToStartDownload {
url: Box::new(url.clone()),
error,
})?;
Ok(Response::Real(res, permit))
}
}
#[cfg(test)]
impl Network {
pub(crate) fn new_mock() -> Self {
let mut network = Network {
client: Client::new(),
connection_semaphore: tokio::sync::Semaphore::new(MAX_CONCURRENT_CONNECTIONS),
source_file_cache: Default::default(),
use_git_credentials: false, #[cfg(test)]
mock_network: Some(Default::default()),
};
network.mock_serve_toml(
crate::storage::REGISTRY_URL,
&crate::format::RegistryFile::default(),
);
network
}
pub(crate) fn mock_serve(&mut self, url: impl AsRef<str>, data: impl AsRef<[u8]>) {
self.mock_network
.as_mut()
.expect("not a mock network")
.insert(
url.as_ref().parse().unwrap(),
Bytes::copy_from_slice(data.as_ref()),
);
}
pub(crate) fn mock_serve_toml(&mut self, url: impl AsRef<str>, data: &impl serde::Serialize) {
self.mock_serve(
url,
crate::serialization::to_formatted_toml(data, None)
.unwrap()
.to_string(),
);
}
pub(crate) fn mock_serve_json(&mut self, url: impl AsRef<str>, data: &impl serde::Serialize) {
self.mock_serve(url, serde_json::to_string(data).unwrap());
}
}