use std::io::{BufRead, Read, Write};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use dynamic_config::Error;
use dynamic_config_store_core::tls::TlsConfig;
use gix::protocol::transport::client::blocking_io::http::{
Error as HttpError, GetResponse, Http, PostBodyDataKind, PostResponse,
};
use crate::url::redacted;
pub(crate) type Transport = gix::protocol::transport::client::blocking_io::http::Transport<Client>;
pub(crate) fn check_scheme(url: &str, tls: &TlsConfig) -> Result<(), Error> {
if tls.is_empty() || url.starts_with("https://") {
return Ok(());
}
Err(Error::remote(format!(
"git {}: `tls` configures the https transport, and this url is not an \
https one; an ssh remote authenticates its host through `known_hosts` \
and its client through a key, which is `Credential::ssh_agent`, \
`ssh_key` or `ssh_command`",
redacted(url)
)))
}
pub(crate) fn transport(
url: &gix::Url,
version: gix::protocol::transport::Protocol,
tls: &TlsConfig,
timeout: Duration,
trace: bool,
described: &str,
) -> Result<Transport, Error> {
Ok(Transport::new_http(
Client::new(tls, timeout, described)?,
url.clone(),
version,
trace,
))
}
pub(crate) struct Client {
client: reqwest::blocking::Client,
described: String,
timeout: Duration,
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("for", &self.described)
.finish_non_exhaustive()
}
}
impl Client {
fn new(tls: &TlsConfig, timeout: Duration, described: &str) -> Result<Self, Error> {
let mut builder = reqwest::blocking::ClientBuilder::new()
.connect_timeout(timeout)
.timeout(timeout)
.http1_title_case_headers()
.redirect(reqwest::redirect::Policy::none());
if let Some(pem) = tls.ca_certificate_pem(described)? {
let authorities = reqwest::Certificate::from_pem_bundle(&pem).map_err(|error| {
Error::remote(format!(
"{described}: the CA certificate is not a PEM certificate: {error}"
))
})?;
if authorities.is_empty() {
return Err(Error::remote(format!(
"{described}: the CA certificate is not a PEM certificate: it holds \
no `BEGIN CERTIFICATE` block at all"
)));
}
for certificate in authorities {
builder = builder.add_root_certificate(certificate);
}
}
if let Some((certificate, key)) = tls.client_certificate_pem(described)? {
let mut pem = certificate;
pem.extend_from_slice(b"\n");
pem.extend_from_slice(&key);
let identity = reqwest::Identity::from_pem(&pem).map_err(|error| {
Error::remote(format!(
"{described}: the client certificate and key are not a \
usable PEM pair: {error}"
))
})?;
builder = builder.identity(identity);
}
let client = builder.build().map_err(|error| {
Error::remote(format!("{described}: cannot build a TLS client: {error}"))
})?;
Ok(Self {
client,
described: described.to_owned(),
timeout,
})
}
fn exchange(
&self,
url: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
body: Option<PostBodyDataKind>,
) -> Arc<Mutex<Exchange>> {
let mut header_map = reqwest::header::HeaderMap::new();
for line in headers {
let Some((name, value)) = line.as_ref().split_once(':') else {
continue;
};
if let Ok(name) = reqwest::header::HeaderName::try_from(name) {
if let Ok(value) = reqwest::header::HeaderValue::try_from(value.trim()) {
header_map.append(name, value);
}
}
}
Arc::new(Mutex::new(Exchange::Pending {
client: self.client.clone(),
described: self.described.clone(),
timeout: self.timeout,
url: url.to_owned(),
headers: header_map,
posting: body.is_some(),
body: Vec::new(),
}))
}
}
enum Exchange {
Pending {
client: reqwest::blocking::Client,
described: String,
timeout: Duration,
url: String,
headers: reqwest::header::HeaderMap,
posting: bool,
body: Vec<u8>,
},
Answered {
headers: Vec<u8>,
response: Option<reqwest::blocking::Response>,
},
Failed(String, std::io::ErrorKind),
}
impl Exchange {
fn send(&mut self) {
let Self::Pending {
client,
described,
timeout,
url,
headers,
posting,
body,
} = self
else {
return;
};
let request = if *posting {
client.post(url.as_str()).body(std::mem::take(body))
} else {
client.get(url.as_str())
};
let described = described.clone();
let timeout = *timeout;
*self = match request.headers(std::mem::take(headers)).send() {
Err(error) if error.is_timeout() => Self::Failed(
format!(
"{described}: the host did not answer within {timeout:?}; \
raise `with_timeout` if that is too short for this repository"
),
std::io::ErrorKind::TimedOut,
),
Err(error) => Self::Failed(
format!("{described}: {}", redacted(&crate::fetch::chain(&error))),
std::io::ErrorKind::Other,
),
Ok(response) => {
let status = response.status();
if status.is_success() {
let mut rendered = Vec::new();
for (name, value) in response.headers() {
rendered.extend_from_slice(name.as_str().as_bytes());
rendered.push(b':');
rendered.extend_from_slice(value.as_bytes());
rendered.push(b'\n');
}
Self::Answered {
headers: rendered,
response: Some(response),
}
} else if status.is_redirection() {
Self::Failed(
format!(
"{described}: the host answered {status} — a redirect, which \
this transport does not follow, because a fetch is two \
requests against one base url and only this host knows the \
new one; name the url it is redirecting to",
),
std::io::ErrorKind::Other,
)
} else {
Self::Failed(
format!("{described}: the host answered HTTP {status}"),
if status == reqwest::StatusCode::UNAUTHORIZED {
std::io::ErrorKind::PermissionDenied
} else if status.is_server_error() {
std::io::ErrorKind::ConnectionAborted
} else {
std::io::ErrorKind::Other
},
)
}
}
};
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Half {
Headers,
Body,
}
pub(crate) struct Reader {
exchange: Arc<Mutex<Exchange>>,
half: Half,
inner: Option<Box<dyn BufRead>>,
}
impl Reader {
fn ready(&mut self) -> std::io::Result<&mut Box<dyn BufRead>> {
if self.inner.is_none() {
let mut exchange = self
.exchange
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
exchange.send();
self.inner = Some(match &mut *exchange {
Exchange::Failed(why, kind) => {
return Err(std::io::Error::new(*kind, why.clone()));
}
Exchange::Answered { headers, response } => match self.half {
Half::Headers => Box::new(std::io::Cursor::new(std::mem::take(headers))),
Half::Body => match response.take() {
Some(response) => Box::new(std::io::BufReader::new(response)),
None => Box::new(std::io::empty()),
},
},
Exchange::Pending { .. } => Box::new(std::io::empty()),
});
}
Ok(self.inner.as_mut().expect("just filled in"))
}
}
impl Read for Reader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
self.ready()?.read(buffer)
}
}
impl BufRead for Reader {
fn fill_buf(&mut self) -> std::io::Result<&[u8]> {
self.ready()?.fill_buf()
}
fn consume(&mut self, amount: usize) {
if let Some(inner) = self.inner.as_mut() {
inner.consume(amount);
}
}
}
pub(crate) struct Body(Arc<Mutex<Exchange>>);
impl Write for Body {
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
if let Exchange::Pending { body, .. } = &mut *self
.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
{
body.extend_from_slice(buffer);
}
Ok(buffer.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl Http for Client {
type Headers = Reader;
type ResponseBody = Reader;
type PostBody = Body;
fn get(
&mut self,
url: &str,
_base_url: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
) -> Result<GetResponse<Self::Headers, Self::ResponseBody>, HttpError> {
let exchange = self.exchange(url, headers, None);
Ok(GetResponse {
headers: Reader {
exchange: Arc::clone(&exchange),
half: Half::Headers,
inner: None,
},
body: Reader {
exchange,
half: Half::Body,
inner: None,
},
})
}
fn post(
&mut self,
url: &str,
_base_url: &str,
headers: impl IntoIterator<Item = impl AsRef<str>>,
body: PostBodyDataKind,
) -> Result<PostResponse<Self::Headers, Self::ResponseBody, Self::PostBody>, HttpError> {
let exchange = self.exchange(url, headers, Some(body));
Ok(PostResponse {
post_body: Body(Arc::clone(&exchange)),
headers: Reader {
exchange: Arc::clone(&exchange),
half: Half::Headers,
inner: None,
},
body: Reader {
exchange,
half: Half::Body,
inner: None,
},
})
}
fn configure(
&mut self,
_config: &dyn std::any::Any,
) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
const PLANTED: &str = "PLANTED-PRIVATE-KEY-MATERIAL";
fn described() -> &'static str {
"git https://gitlab.internal/acme/config.git main:config.yaml"
}
#[test]
fn tls_on_a_url_that_has_no_tls_in_it_is_refused() {
let tls = TlsConfig::new().with_ca_certificate_file("/etc/ssl/private-ca.pem");
for url in [
"ssh://git@github.com/acme/config.git",
"git@github.com:acme/config.git",
"file:///srv/config.git",
"http://gitlab.internal/acme/config.git",
] {
let error = check_scheme(url, &tls).expect_err("{url} has no TLS to configure");
assert!(
error.to_string().contains("configures the https"),
"{error}"
);
}
check_scheme("https://gitlab.internal/acme/config.git", &tls)
.expect("this one does have TLS in it");
for url in ["ssh://git@github.com/a.git", "file:///srv/config.git"] {
check_scheme(url, &TlsConfig::new()).expect("nothing was asked for");
}
}
#[test]
fn a_bad_client_key_is_refused_without_quoting_it() {
let tls = TlsConfig::new().with_client_certificate_pem(
"-----BEGIN CERTIFICATE-----\nnot-a-certificate\n-----END CERTIFICATE-----\n",
format!("-----BEGIN PRIVATE KEY-----\n{PLANTED}\n-----END PRIVATE KEY-----\n"),
);
let error = Client::new(&tls, Duration::from_secs(5), described())
.expect_err("that is not a usable pair");
let printed = format!("{error} {error:?}");
assert!(!printed.contains(PLANTED), "{printed}");
assert!(printed.contains("not a usable PEM pair"), "{printed}");
}
#[test]
fn a_ca_certificate_that_is_not_one_names_the_setting_and_not_the_bytes() {
let tls = TlsConfig::new().with_ca_certificate_pem(format!("not a pem {PLANTED}"));
let error = Client::new(&tls, Duration::from_secs(5), described())
.expect_err("that is not a certificate");
let printed = format!("{error} {error:?}");
assert!(!printed.contains(PLANTED), "{printed}");
assert!(printed.contains("not a PEM certificate"), "{printed}");
}
#[test]
fn a_ca_file_that_is_not_there_names_the_path() {
let tls = TlsConfig::new().with_ca_certificate_file("/nonexistent/private-ca.pem");
let error = Client::new(&tls, Duration::from_secs(5), described())
.expect_err("the file is not there");
assert!(
error.to_string().contains("/nonexistent/private-ca.pem"),
"{error}"
);
assert!(error.to_string().contains(described()), "{error}");
}
#[test]
fn a_token_in_a_url_does_not_survive_a_transport_failure() {
let client = Client::new(&TlsConfig::new(), Duration::from_millis(200), described())
.expect("an empty configuration builds a plain client");
let response = client.exchange(
"https://x-access-token:ghs_hunter2@127.0.0.1:1/acme/config.git/info/refs",
["User-Agent: test"],
None,
);
response.lock().unwrap().send();
let Exchange::Failed(why, _) = &*response.lock().unwrap() else {
panic!("nothing is listening on port 1");
};
assert!(!why.contains("hunter2"), "{why}");
}
}