git-transport 0.4.0

A WIP crate of the gitoxide project dedicated to implementing the git transport layer
Documentation
use crate::{
    client::{self, capabilities, ExtendedBufRead, HandleProgress, RequestWriter},
    Protocol, Service,
};
use git_packetline::PacketLine;
use std::{
    borrow::Cow,
    convert::Infallible,
    io::{self, BufRead, Read},
};

#[cfg(feature = "http-client-curl")]
pub(crate) mod curl;

mod traits;
use crate::client::MessageKind;
pub use traits::{Error, GetResponse, Http, PostResponse};

#[cfg(feature = "http-client-curl")]
pub type Impl = curl::Curl;

pub struct Transport<H: Http> {
    url: String,
    user_agent_header: &'static str,
    desired_version: crate::Protocol,
    actual_version: crate::Protocol,
    http: H,
    service: Option<Service>,
    line_provider: Option<git_packetline::Provider<H::ResponseBody>>,
    identity: Option<client::Identity>,
}

impl Transport<Impl> {
    pub fn new(url: &str, version: crate::Protocol) -> Self {
        Transport {
            url: url.to_owned(),
            user_agent_header: concat!("User-Agent: git/oxide-", env!("CARGO_PKG_VERSION")),
            desired_version: version,
            actual_version: version,
            service: None,
            http: Impl::default(),
            line_provider: None,
            identity: None,
        }
    }
}

impl<H: Http> Transport<H> {
    fn check_content_type(service: Service, kind: &str, headers: <H as Http>::Headers) -> Result<(), client::Error> {
        let wanted_content_type = format!("Content-Type: application/x-{}-{}", service.as_str(), kind);
        if !headers
            .lines()
            .collect::<Result<Vec<_>, _>>()?
            .iter()
            .any(|l| l == &wanted_content_type)
        {
            return Err(client::Error::Http(Error::Detail(format!(
                "Didn't find '{}' header to indicate 'smart' protocol, and 'dumb' protocol is not supported.",
                wanted_content_type
            ))));
        }
        Ok(())
    }

    fn add_basic_auth_if_present(&self, headers: &mut Vec<Cow<'_, str>>) -> Result<(), client::Error> {
        if let Some(identity) = &self.identity {
            match identity {
                client::Identity::Account { username, password } => {
                    #[cfg(not(debug_assertions))]
                    if self.url.starts_with("http://") {
                        return Err(client::Error::AuthenticationRefused(
                            "Will not send credentials in clear text over http",
                        ));
                    }
                    headers.push(Cow::Owned(format!(
                        "Authorization: Basic {}",
                        base64::encode(format!("{}:{}", username, password))
                    )))
                }
            }
        }
        Ok(())
    }
}

fn append_url(base: &str, suffix: &str) -> String {
    if base.ends_with('/') {
        format!("{}{}", base, suffix)
    } else {
        format!("{}/{}", base, suffix)
    }
}

impl<H: Http> client::Transport for Transport<H> {
    fn handshake(&mut self, service: Service) -> Result<client::SetServiceResponse<'_>, client::Error> {
        let url = append_url(&self.url, &format!("info/refs?service={}", service.as_str()));
        let static_headers = [Cow::Borrowed(self.user_agent_header)];
        let mut dynamic_headers = Vec::<Cow<'_, str>>::new();
        if self.desired_version != Protocol::V1 {
            dynamic_headers.push(Cow::Owned(format!(
                "Git-Protocol: version={}",
                self.desired_version as usize
            )));
        }
        self.add_basic_auth_if_present(&mut dynamic_headers)?;
        let GetResponse { headers, body } = self.http.get(&url, static_headers.iter().chain(&dynamic_headers))?;
        <Transport<H>>::check_content_type(service, "advertisement", headers)?;

        let line_reader = self
            .line_provider
            .get_or_insert_with(|| git_packetline::Provider::new(body, &[PacketLine::Flush]));

        let mut announced_service = String::new();
        line_reader.as_read().read_to_string(&mut announced_service)?;
        let expected_service_announcement = format!("# service={}", service.as_str());
        if announced_service.trim() != expected_service_announcement {
            return Err(client::Error::Http(Error::Detail(format!(
                "Expected to see {:?}, but got {:?}",
                expected_service_announcement,
                announced_service.trim()
            ))));
        }

        let capabilities::recv::Outcome {
            capabilities,
            refs,
            protocol: actual_protocol,
        } = capabilities::recv::v1_or_v2_as_detected(line_reader)?;
        self.actual_version = actual_protocol;
        self.service = Some(service);
        Ok(client::SetServiceResponse {
            actual_protocol,
            capabilities,
            refs,
        })
    }

    fn set_identity(&mut self, identity: client::Identity) -> Result<(), client::Error> {
        self.identity = Some(identity);
        Ok(())
    }

    fn request(
        &mut self,
        write_mode: client::WriteMode,
        on_into_read: client::MessageKind,
    ) -> Result<client::RequestWriter<'_>, client::Error> {
        let service = self.service.expect("handshake() must have been called first");
        let url = append_url(&self.url, service.as_str());
        let static_headers = &[
            Cow::Borrowed(self.user_agent_header),
            Cow::Owned(format!("Content-Type: application/x-{}-request", service.as_str())),
            format!("Accept: application/x-{}-result", service.as_str()).into(),
            "Expect:".into(), // needed to avoid sending Expect: 100-continue, which adds another response and only CURL wants that
        ];
        let mut dynamic_headers = Vec::new();
        self.add_basic_auth_if_present(&mut dynamic_headers)?;
        if self.actual_version != Protocol::V1 {
            dynamic_headers.push(Cow::Owned(format!(
                "Git-Protocol: version={}",
                self.actual_version as usize
            )));
        }

        let PostResponse {
            headers,
            body,
            post_body,
        } = self.http.post(&url, static_headers.iter().chain(&dynamic_headers))?;
        let line_provider = self
            .line_provider
            .as_mut()
            .expect("handshake to have been called first");
        line_provider.replace(body);
        Ok(RequestWriter::new_from_bufread(
            post_body,
            Box::new(HeadersThenBody::<H, _> {
                service,
                headers: Some(headers),
                body: line_provider.as_read_without_sidebands(),
            }),
            write_mode,
            on_into_read,
        ))
    }

    fn close(&mut self) -> Result<(), client::Error> {
        Ok(())
    }

    fn to_url(&self) -> String {
        self.url.to_owned()
    }

    fn desired_protocol_version(&self) -> Protocol {
        self.desired_version
    }

    fn is_stateful(&self) -> bool {
        false
    }
}

struct HeadersThenBody<H: Http, B> {
    service: Service,
    headers: Option<H::Headers>,
    body: B,
}

impl<H: Http, B> HeadersThenBody<H, B> {
    fn handle_headers(&mut self) -> io::Result<()> {
        if let Some(headers) = self.headers.take() {
            <Transport<H>>::check_content_type(self.service, "result", headers)
                .map_err(|err| io::Error::new(io::ErrorKind::Other, err))?
        }
        Ok(())
    }
}

impl<H: Http, B: ExtendedBufRead> io::Read for HeadersThenBody<H, B> {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        self.handle_headers()?;
        self.body.read(buf)
    }
}

impl<H: Http, B: ExtendedBufRead> io::BufRead for HeadersThenBody<H, B> {
    fn fill_buf(&mut self) -> io::Result<&[u8]> {
        self.handle_headers()?;
        self.body.fill_buf()
    }

    fn consume(&mut self, amt: usize) {
        self.body.consume(amt)
    }
}

impl<H: Http, B: ExtendedBufRead> ExtendedBufRead for HeadersThenBody<H, B> {
    fn set_progress_handler(&mut self, handle_progress: Option<HandleProgress>) {
        self.body.set_progress_handler(handle_progress)
    }

    fn peek_data_line(&mut self) -> Option<io::Result<Result<&[u8], client::Error>>> {
        if let Err(err) = self.handle_headers() {
            return Some(Err(err));
        }
        self.body.peek_data_line()
    }

    fn reset(&mut self, version: Protocol) {
        self.body.reset(version)
    }

    fn stopped_at(&self) -> Option<MessageKind> {
        self.body.stopped_at()
    }
}

pub fn connect(url: &str, version: crate::Protocol) -> Result<Transport<Impl>, Infallible> {
    Ok(Transport::new(url, version))
}