use std::io::{self, Read, Write};
use std::sync::Mutex;
use std::time::Duration;
use git2::transport::{Service, SmartSubtransport, SmartSubtransportStream, Transport};
use git2::{Error, Remote};
use reqwest::blocking::{Client, Response};
use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
const UA: &str = concat!("torii/", env!("CARGO_PKG_VERSION"));
fn http_timeout() -> Duration {
let secs = std::env::var("TORII_HTTP_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(300); Duration::from_secs(secs)
}
pub fn factory(remote: &Remote<'_>) -> Result<Transport, Error> {
Transport::smart(remote, true, HttpsSubtransport::new())
}
struct HttpsSubtransport {
client: Client,
}
impl HttpsSubtransport {
fn new() -> Self {
let client = Client::builder()
.user_agent(UA)
.connect_timeout(Duration::from_secs(10))
.timeout(http_timeout())
.build()
.expect("reqwest client");
Self { client }
}
}
impl SmartSubtransport for HttpsSubtransport {
fn action(
&self,
url: &str,
action: Service,
) -> Result<Box<dyn SmartSubtransportStream>, Error> {
let auth = resolve_auth(url);
let stream = match action {
Service::UploadPackLs => {
HttpStream::ls(&self.client, url, "git-upload-pack", auth)?
}
Service::ReceivePackLs => {
HttpStream::ls(&self.client, url, "git-receive-pack", auth)?
}
Service::UploadPack => {
HttpStream::rpc(self.client.clone(), url, "git-upload-pack", auth)
}
Service::ReceivePack => {
HttpStream::rpc(self.client.clone(), url, "git-receive-pack", auth)
}
};
Ok(Box::new(stream))
}
fn close(&self) -> Result<(), Error> {
Ok(())
}
}
struct HttpStream {
inner: Mutex<Inner>,
}
enum Inner {
Ls { resp: Response },
Rpc {
client: Client,
url: String,
service: &'static str,
auth: Option<String>,
sent: bool,
req_body: Vec<u8>,
resp: Option<Response>,
},
}
impl HttpStream {
fn ls(
client: &Client,
base_url: &str,
service: &str,
auth: Option<String>,
) -> Result<Self, Error> {
let url = format!("{}/info/refs?service={}", base_url.trim_end_matches('/'), service);
let mut req = client.get(&url).header(USER_AGENT, UA).header(ACCEPT, "*/*");
if let Some(a) = &auth {
req = req.header(AUTHORIZATION, a);
}
let resp = req.send().map_err(io_err)?;
let resp = check_status(resp, base_url, auth.is_some())?;
Ok(Self {
inner: Mutex::new(Inner::Ls { resp }),
})
}
fn rpc(
client: Client,
base_url: &str,
service: &'static str,
auth: Option<String>,
) -> Self {
let url = format!("{}/{}", base_url.trim_end_matches('/'), service);
Self {
inner: Mutex::new(Inner::Rpc {
client,
url,
service,
auth,
sent: false,
req_body: Vec::new(),
resp: None,
}),
}
}
}
impl Read for HttpStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let mut inner = self.inner.lock().unwrap();
match &mut *inner {
Inner::Ls { resp } => resp.read(buf),
Inner::Rpc {
client,
url,
service,
auth,
sent,
req_body,
resp,
} => {
if !*sent {
let body = std::mem::take(req_body);
let req_ct = format!("application/x-{}-request", service);
let resp_ct = format!("application/x-{}-result", service);
let mut req = client
.post(url.as_str())
.header(USER_AGENT, UA)
.header(CONTENT_TYPE, req_ct)
.header(ACCEPT, resp_ct)
.body(body);
if let Some(a) = auth.as_deref() {
req = req.header(AUTHORIZATION, a);
}
let r = req.send().map_err(to_io)?;
let r = check_status(r, url, auth.is_some())
.map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
*resp = Some(r);
*sent = true;
}
resp.as_mut().unwrap().read(buf)
}
}
}
}
impl Write for HttpStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let mut inner = self.inner.lock().unwrap();
match &mut *inner {
Inner::Ls { .. } => Err(io::Error::new(
io::ErrorKind::Unsupported,
"cannot write to Ls stream",
)),
Inner::Rpc { req_body, sent, .. } => {
if *sent {
return Err(io::Error::new(
io::ErrorKind::Other,
"cannot write after read started",
));
}
req_body.extend_from_slice(buf);
Ok(buf.len())
}
}
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
fn io_err(e: reqwest::Error) -> Error {
Error::from_str(&format!("https transport: {}", e))
}
fn to_io(e: reqwest::Error) -> io::Error {
io::Error::new(io::ErrorKind::Other, e)
}
fn check_status(resp: Response, base_url: &str, had_auth: bool) -> Result<Response, Error> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let host = host_of(base_url).unwrap_or_else(|| "remote".to_string());
let msg = match status.as_u16() {
401 if !had_auth => format!(
"{} requires auth (HTTP 401). Set {} or TORII_HTTPS_TOKEN.",
host,
env_var_name_for(&host).unwrap_or("a host token")
),
401 => format!(
"{} rejected the credentials (HTTP 401). Check token scope and validity.",
host
),
403 => format!(
"{} forbade the request (HTTP 403). Token may lack scope or repo is restricted.",
host
),
404 => format!(
"{} returned 404. Repo may not exist or token cannot see it.",
host
),
s => format!("{} returned HTTP {}", host, s),
};
Err(Error::from_str(&format!("https transport: {}", msg)))
}
fn env_var_name_for(host: &str) -> Option<&'static str> {
Some(match host {
h if h.contains("github.") => "GITHUB_TOKEN",
h if h.contains("gitlab.") => "GITLAB_TOKEN",
h if h.contains("codeberg.") => "CODEBERG_TOKEN",
h if h.contains("bitbucket.") => "BITBUCKET_TOKEN",
h if h.contains("gitea.") => "GITEA_TOKEN",
h if h.contains("forgejo.") => "FORGEJO_TOKEN",
h if h.contains("sr.ht") || h.contains("sourcehut.") => "SOURCEHUT_TOKEN",
_ => return None,
})
}
fn resolve_auth(url: &str) -> Option<String> {
let host = host_of(url)?;
let token = env_token_for(&host)
.or_else(|| std::env::var("TORII_HTTPS_TOKEN").ok())
.or_else(|| config_token_for(&host))?;
Some(basic_auth("x-access-token", &token))
}
fn config_token_for(host: &str) -> Option<String> {
let provider = if host.contains("github.") {
"github"
} else if host.contains("gitlab.") {
"gitlab"
} else if host.contains("codeberg.") {
"codeberg"
} else if host.contains("gitea.") {
"gitea"
} else if host.contains("forgejo.") {
"forgejo"
} else if host.contains("bitbucket.") {
"bitbucket"
} else {
return None;
};
crate::auth::resolve_token(provider, ".").value
}
fn host_of(url: &str) -> Option<String> {
let after_scheme = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
let host_port = after_scheme.split('/').next().unwrap_or("");
let host = host_port.split(':').next().unwrap_or("");
if host.is_empty() {
None
} else {
Some(host.to_ascii_lowercase())
}
}
fn env_token_for(host: &str) -> Option<String> {
let var = match host {
h if h.contains("github.") => "GITHUB_TOKEN",
h if h.contains("gitlab.") => "GITLAB_TOKEN",
h if h.contains("codeberg.") => "CODEBERG_TOKEN",
h if h.contains("bitbucket.") => "BITBUCKET_TOKEN",
h if h.contains("gitea.") => "GITEA_TOKEN",
h if h.contains("forgejo.") => "FORGEJO_TOKEN",
h if h.contains("sr.ht") || h.contains("sourcehut.") => "SOURCEHUT_TOKEN",
_ => return None,
};
std::env::var(var).ok().filter(|s| !s.is_empty())
}
fn basic_auth(user: &str, pass: &str) -> String {
let raw = format!("{}:{}", user, pass);
format!("Basic {}", base64_encode(raw.as_bytes()))
}
fn base64_encode(input: &[u8]) -> String {
const TAB: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((input.len() + 2) / 3 * 4);
let mut i = 0;
while i + 3 <= input.len() {
let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8) | input[i + 2] as u32;
out.push(TAB[((n >> 18) & 0x3F) as usize] as char);
out.push(TAB[((n >> 12) & 0x3F) as usize] as char);
out.push(TAB[((n >> 6) & 0x3F) as usize] as char);
out.push(TAB[(n & 0x3F) as usize] as char);
i += 3;
}
let rem = input.len() - i;
if rem == 1 {
let n = (input[i] as u32) << 16;
out.push(TAB[((n >> 18) & 0x3F) as usize] as char);
out.push(TAB[((n >> 12) & 0x3F) as usize] as char);
out.push('=');
out.push('=');
} else if rem == 2 {
let n = ((input[i] as u32) << 16) | ((input[i + 1] as u32) << 8);
out.push(TAB[((n >> 18) & 0x3F) as usize] as char);
out.push(TAB[((n >> 12) & 0x3F) as usize] as char);
out.push(TAB[((n >> 6) & 0x3F) as usize] as char);
out.push('=');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_extraction() {
assert_eq!(host_of("https://github.com/owner/repo").as_deref(), Some("github.com"));
assert_eq!(
host_of("https://gitlab.example.com:8443/group/repo").as_deref(),
Some("gitlab.example.com")
);
assert_eq!(host_of("https://codeberg.org/foo/bar").as_deref(), Some("codeberg.org"));
}
#[test]
fn base64_known_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn basic_auth_format() {
assert_eq!(basic_auth("x-access-token", "secret"), "Basic eC1hY2Nlc3MtdG9rZW46c2VjcmV0");
}
}