use std::io::Read;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::Duration;
use base64::Engine;
use crate::config::{self, ConfigSet};
use crate::credentials::{Credential, CredentialProvider};
use crate::error::{Error, Result};
use super::HttpClient;
pub struct UreqHttpClient {
agent: ureq::Agent,
user_agent: String,
git_protocol: Option<String>,
credentials: Option<Box<dyn CredentialProvider + Send + Sync>>,
cached_auth: Mutex<Option<String>>,
cookies: Vec<CookieSpec>,
cookie_file_path: Option<PathBuf>,
save_cookies: bool,
extra_headers: Vec<ExtraHeaderRule>,
}
impl UreqHttpClient {
#[must_use]
pub fn new() -> Self {
Self::with_agent(default_agent(None))
}
fn with_agent(agent: ureq::Agent) -> Self {
Self {
agent,
user_agent: default_user_agent(),
git_protocol: None,
credentials: None,
cached_auth: Mutex::new(None),
cookies: Vec::new(),
cookie_file_path: None,
save_cookies: false,
extra_headers: Vec::new(),
}
}
#[must_use]
pub fn with_credentials(provider: Box<dyn CredentialProvider + Send + Sync>) -> Self {
let mut c = Self::new();
c.credentials = Some(provider);
c
}
pub fn from_config(config: &ConfigSet) -> Result<Self> {
let proxy = build_proxy(config)?;
let agent = default_agent(proxy);
let mut client = Self::with_agent(agent);
client.cookie_file_path = config
.get("http.cookieFile")
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.map(PathBuf::from);
client.cookies = build_cookie_specs(client.cookie_file_path.as_deref())?;
client.save_cookies = client.cookie_file_path.is_some()
&& config
.get_bool("http.saveCookies")
.and_then(std::result::Result::ok)
.unwrap_or(false);
client.extra_headers = extra_header_rules_from_config(config);
Ok(client)
}
#[must_use]
pub fn with_credential_provider(
mut self,
provider: Box<dyn CredentialProvider + Send + Sync>,
) -> Self {
self.credentials = Some(provider);
self
}
#[must_use]
pub fn with_git_protocol(mut self, value: impl Into<String>) -> Self {
self.git_protocol = Some(value.into());
self
}
#[must_use]
pub fn with_user_agent(mut self, ua: impl Into<String>) -> Self {
self.user_agent = ua.into();
self
}
fn cached_auth_header(&self) -> Option<String> {
self.cached_auth.lock().ok().and_then(|g| g.clone())
}
fn store_auth_header(&self, header: String) {
if let Ok(mut g) = self.cached_auth.lock() {
*g = Some(header);
}
}
fn clear_auth_header(&self) {
if let Ok(mut g) = self.cached_auth.lock() {
*g = None;
}
}
fn cookie_header_for_url(&self, url: &str) -> Option<String> {
if self.cookies.is_empty() {
return None;
}
let parsed = url::Url::parse(url).ok();
let parts = self
.cookies
.iter()
.filter(|cookie| cookie.matches_url(parsed.as_ref()))
.map(|cookie| cookie.name_value.clone())
.collect::<Vec<_>>();
(!parts.is_empty()).then(|| parts.join("; "))
}
fn extra_headers_for_url(&self, url: &str) -> Vec<(String, String)> {
let mut headers = Vec::new();
for rule in &self.extra_headers {
let matches = rule
.pattern
.as_deref()
.is_none_or(|pattern| config::url_matches(pattern, url));
if !matches {
continue;
}
match &rule.header {
Some(header) => headers.push(header.clone()),
None => headers.clear(),
}
}
headers
}
fn save_response_cookies(&self, set_cookies: &[String]) {
if !self.save_cookies {
return;
}
let Some(path) = self.cookie_file_path.as_ref() else {
return;
};
let values: Vec<&str> = set_cookies
.iter()
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.collect();
if values.is_empty() {
return;
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
use std::io::Write as _;
for value in values {
let _ = writeln!(file, "Set-Cookie: {value}");
}
}
}
}
impl Default for UreqHttpClient {
fn default() -> Self {
Self::new()
}
}
struct RawResponse {
status: u16,
www_authenticate: Vec<String>,
set_cookie: Vec<String>,
body: Vec<u8>,
}
fn default_user_agent() -> String {
format!("grit-lib/{}", env!("CARGO_PKG_VERSION"))
}
fn default_agent(proxy: Option<ureq::Proxy>) -> ureq::Agent {
let mut builder = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(30))
.timeout(Duration::from_secs(600));
if let Some(proxy) = proxy {
builder = builder.proxy(proxy);
}
builder.build()
}
fn build_proxy(config: &ConfigSet) -> Result<Option<ureq::Proxy>> {
let Some(raw) = config.get("http.proxy") else {
return Ok(None);
};
let raw = raw.trim();
if raw.is_empty() {
return Ok(None);
}
let with_scheme = if raw.contains("://") {
raw.to_string()
} else {
format!("http://{raw}")
};
let scheme = with_scheme
.split_once("://")
.map(|(s, _)| s.to_ascii_lowercase())
.unwrap_or_default();
if scheme.starts_with("socks") {
return Err(Error::Message(format!(
"http.proxy '{raw}': SOCKS proxies are not supported by the default ureq HTTP client; \
implement a custom HttpClient or use an HTTP proxy"
)));
}
if scheme != "http" && scheme != "https" {
return Err(Error::Message(format!(
"http.proxy '{raw}': unsupported proxy scheme '{scheme}'"
)));
}
let proxy = ureq::Proxy::new(&with_scheme)
.map_err(|e| Error::Message(format!("invalid http.proxy '{raw}': {e}")))?;
Ok(Some(proxy))
}
fn credential_for_url(url: &str) -> Option<Credential> {
let parsed = url::Url::parse(url).ok()?;
let protocol = parsed.scheme().to_string();
let host = parsed.host_str()?.to_string();
let host = if let Some(port) = parsed.port() {
format!("{host}:{port}")
} else {
host
};
let path = parsed.path().trim_start_matches('/').to_string();
Some(Credential {
protocol: Some(protocol),
host: Some(host),
path: if path.is_empty() { None } else { Some(path) },
url: Some(url.to_string()),
..Default::default()
})
}
fn basic_auth_header(cred: &Credential) -> Option<String> {
let user = cred.username.as_deref()?;
let pass = cred.password.as_deref().unwrap_or("");
let raw = format!("{user}:{pass}");
let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes());
Some(format!("Basic {encoded}"))
}
fn offers_basic(challenges: &[String]) -> bool {
challenges.is_empty()
|| challenges
.iter()
.any(|c| c.trim_start().to_ascii_lowercase().starts_with("basic"))
}
impl UreqHttpClient {
fn do_get(
&self,
url: &str,
git_protocol: Option<&str>,
auth: Option<&str>,
) -> Result<RawResponse> {
let mut req = self.agent.get(url).set("User-Agent", &self.user_agent);
if let Some(v) = git_protocol {
req = req.set("Git-Protocol", v);
}
if let Some(a) = auth {
req = req.set("Authorization", a);
}
if let Some(cookie) = self.cookie_header_for_url(url) {
req = req.set("Cookie", &cookie);
}
for (name, value) in self.extra_headers_for_url(url) {
req = req.set(&name, &value);
}
finish(req.call())
}
fn do_post(
&self,
url: &str,
content_type: &str,
accept: &str,
body: &[u8],
git_protocol: Option<&str>,
auth: Option<&str>,
) -> Result<RawResponse> {
let mut req = self
.agent
.post(url)
.set("Content-Type", content_type)
.set("Accept", accept)
.set("User-Agent", &self.user_agent);
if let Some(v) = git_protocol {
req = req.set("Git-Protocol", v);
}
if let Some(a) = auth {
req = req.set("Authorization", a);
}
if let Some(cookie) = self.cookie_header_for_url(url) {
req = req.set("Cookie", &cookie);
}
for (name, value) in self.extra_headers_for_url(url) {
req = req.set(&name, &value);
}
finish(req.send_bytes(body))
}
fn with_auth_retry<F>(&self, url: &str, attempt: F) -> Result<Vec<u8>>
where
F: Fn(Option<&str>) -> Result<RawResponse>,
{
let initial_auth = self.cached_auth_header();
let first = attempt(initial_auth.as_deref())?;
if first.status != 401 {
self.save_response_cookies(&first.set_cookie);
return finalize_status(url, first.status, first.body);
}
let Some(provider) = self.credentials.as_ref() else {
self.clear_auth_header();
return Err(Error::Auth(format!(
"{url}: server requires authentication (401) but no credential provider is configured"
)));
};
if !offers_basic(&first.www_authenticate) {
return Err(Error::Auth(format!(
"{url}: server requires an unsupported auth scheme: {:?}",
first.www_authenticate
)));
}
let Some(input) = credential_for_url(url) else {
return Err(Error::Auth(format!(
"{url}: server requires authentication (401) but the URL could not be decomposed into credential fields"
)));
};
let cred = provider
.fill(&input)
.map_err(|e| Error::Auth(format!("{url}: could not obtain credentials: {e}")))?;
let Some(header) = basic_auth_header(&cred) else {
return Err(Error::Auth(format!(
"{url}: credential helper returned no usable username/password"
)));
};
let retry = attempt(Some(&header))?;
if retry.status == 401 {
let _ = provider.reject(&cred);
self.clear_auth_header();
return Err(Error::Auth(format!(
"{url}: supplied credentials were rejected (401)"
)));
}
if retry.status >= 400 {
let _ = provider.reject(&cred);
self.clear_auth_header();
return Err(http_status_error(url, retry.status));
}
let _ = provider.approve(&cred);
self.store_auth_header(header);
self.save_response_cookies(&retry.set_cookie);
Ok(retry.body)
}
}
impl HttpClient for UreqHttpClient {
fn get(&self, url: &str, git_protocol: Option<&str>) -> Result<Vec<u8>> {
let gp = git_protocol.or(self.git_protocol.as_deref());
self.with_auth_retry(url, |auth| self.do_get(url, gp, auth))
}
fn post(
&self,
url: &str,
content_type: &str,
accept: &str,
body: &[u8],
git_protocol: Option<&str>,
) -> Result<Vec<u8>> {
let gp = git_protocol.or(self.git_protocol.as_deref());
self.with_auth_retry(url, |auth| {
self.do_post(url, content_type, accept, body, gp, auth)
})
}
fn git_protocol_header(&self) -> Option<&str> {
self.git_protocol.as_deref()
}
}
fn finish(result: std::result::Result<ureq::Response, ureq::Error>) -> Result<RawResponse> {
match result {
Ok(resp) => Ok(read_response(resp)),
Err(ureq::Error::Status(_code, resp)) => Ok(read_response(resp)),
Err(e) => Err(Error::Message(format!("http transport error: {e}"))),
}
}
fn read_response(resp: ureq::Response) -> RawResponse {
let status = resp.status();
let www_authenticate = resp
.all("WWW-Authenticate")
.into_iter()
.map(std::string::ToString::to_string)
.collect();
let set_cookie = resp
.all("Set-Cookie")
.into_iter()
.map(std::string::ToString::to_string)
.collect();
let mut body = Vec::new();
let _ = resp.into_reader().read_to_end(&mut body);
RawResponse {
status,
www_authenticate,
set_cookie,
body,
}
}
fn finalize_status(url: &str, status: u16, body: Vec<u8>) -> Result<Vec<u8>> {
if status >= 400 {
return Err(http_status_error(url, status));
}
Ok(body)
}
fn http_status_error(url: &str, status: u16) -> Error {
Error::Message(format!("HTTP {status} from {url}"))
}
#[derive(Clone)]
struct ExtraHeaderRule {
pattern: Option<String>,
header: Option<(String, String)>,
}
fn extra_header_rules_from_config(config: &ConfigSet) -> Vec<ExtraHeaderRule> {
let mut rules = Vec::new();
for entry in config.entries() {
let Some((pattern, variable)) = parse_http_config_key(&entry.key) else {
continue;
};
if !variable.eq_ignore_ascii_case("extraheader") {
continue;
}
let pattern = pattern.map(ToOwned::to_owned);
let Some(raw) = entry.value.as_deref() else {
rules.push(ExtraHeaderRule {
pattern,
header: None,
});
continue;
};
if raw.trim().is_empty() {
rules.push(ExtraHeaderRule {
pattern,
header: None,
});
continue;
}
if let Some((name, value)) = raw.split_once(':') {
let name = name.trim();
if !name.is_empty() {
rules.push(ExtraHeaderRule {
pattern,
header: Some((name.to_string(), value.trim_start().to_string())),
});
}
}
}
rules
}
fn parse_http_config_key(key: &str) -> Option<(Option<&str>, &str)> {
let first_dot = key.find('.')?;
let section = &key[..first_dot];
if !section.eq_ignore_ascii_case("http") {
return None;
}
let rest = &key[first_dot + 1..];
if let Some(last_dot) = rest.rfind('.') {
let subsection = &rest[..last_dot];
let variable = &rest[last_dot + 1..];
if subsection.is_empty() || variable.is_empty() {
None
} else {
Some((Some(subsection), variable))
}
} else if rest.is_empty() {
None
} else {
Some((None, rest))
}
}
#[derive(Clone, Debug)]
struct CookieSpec {
name_value: String,
domain: Option<String>,
include_subdomains: bool,
path: Option<String>,
secure: bool,
expires_at: Option<i64>,
}
impl CookieSpec {
fn matches_url(&self, url: Option<&url::Url>) -> bool {
if self.is_expired() {
return false;
}
let Some(url) = url else {
return self.domain.is_none() && self.path.is_none() && !self.secure;
};
if self.secure && url.scheme() != "https" {
return false;
}
if let Some(domain) = self.domain.as_deref() {
let Some(host) = url.host_str() else {
return false;
};
if self.include_subdomains {
if host != domain && !host.ends_with(&format!(".{domain}")) {
return false;
}
} else if host != domain {
return false;
}
}
if let Some(path) = self.path.as_deref() {
if !url.path().starts_with(path) {
return false;
}
}
true
}
fn is_expired(&self) -> bool {
self.expires_at
.is_some_and(|expiry| time::OffsetDateTime::now_utc().unix_timestamp() >= expiry)
}
}
fn build_cookie_specs(path: Option<&std::path::Path>) -> Result<Vec<CookieSpec>> {
let Some(path) = path else {
return Ok(Vec::new());
};
if !path.exists() {
return Ok(Vec::new());
}
let data = std::fs::read_to_string(path)
.map_err(|e| Error::Message(format!("read cookie file '{}': {e}", path.display())))?;
let mut out = Vec::new();
for line in data.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some(spec) = parse_cookie_spec(trimmed) {
out.push(spec);
}
}
Ok(out)
}
fn parse_cookie_spec(line: &str) -> Option<CookieSpec> {
parse_netscape_cookie(line).or_else(|| parse_header_cookie(line))
}
fn parse_netscape_cookie(line: &str) -> Option<CookieSpec> {
let cols: Vec<&str> = line.split('\t').collect();
if cols.len() < 7 {
return None;
}
let domain = cols[0].trim().trim_start_matches('.').to_ascii_lowercase();
if domain.is_empty() {
return None;
}
let include_subdomains =
cols[1].trim().eq_ignore_ascii_case("TRUE") || cols[0].starts_with('.');
let path = cols[2].trim();
let secure = cols[3].trim().eq_ignore_ascii_case("TRUE");
let expires_at = cols[4].trim().parse::<i64>().ok().filter(|v| *v > 0);
let name = cols[5].trim();
let value = cols[6].trim();
if name.is_empty() {
return None;
}
Some(CookieSpec {
name_value: format!("{name}={value}"),
domain: Some(domain),
include_subdomains,
path: (!path.is_empty()).then(|| path.to_string()),
secure,
expires_at,
})
}
fn parse_header_cookie(line: &str) -> Option<CookieSpec> {
let raw = line
.strip_prefix("Set-Cookie:")
.or_else(|| line.strip_prefix("set-cookie:"))
.unwrap_or(line)
.trim();
let mut parts = raw.split(';').map(str::trim);
let name_value = parts.next()?.to_string();
if !name_value.contains('=') {
return None;
}
let mut cookie = CookieSpec {
name_value,
domain: None,
include_subdomains: false,
path: None,
secure: false,
expires_at: None,
};
for attr in parts {
if attr.eq_ignore_ascii_case("secure") {
cookie.secure = true;
continue;
}
if let Some((key, value)) = attr.split_once('=') {
if key.eq_ignore_ascii_case("domain") {
let domain = value.trim().trim_start_matches('.').to_ascii_lowercase();
if !domain.is_empty() {
cookie.include_subdomains = true;
cookie.domain = Some(domain);
}
} else if key.eq_ignore_ascii_case("path") {
let path = value.trim();
if !path.is_empty() {
cookie.path = Some(path.to_string());
}
}
}
}
Some(cookie)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn netscape_cookie_parses_and_matches() {
let line = "example.com\tTRUE\t/\tFALSE\t0\tSID\tabc123";
let spec = parse_cookie_spec(line).expect("parse netscape cookie");
assert_eq!(spec.name_value, "SID=abc123");
let url = url::Url::parse("http://example.com/repo.git/info/refs").unwrap();
assert!(spec.matches_url(Some(&url)));
let other = url::Url::parse("http://other.test/").unwrap();
assert!(!spec.matches_url(Some(&other)));
}
#[test]
fn secure_cookie_only_matches_https() {
let line = "example.com\tTRUE\t/\tTRUE\t0\tS\tv";
let spec = parse_cookie_spec(line).expect("parse");
let http = url::Url::parse("http://example.com/").unwrap();
let https = url::Url::parse("https://example.com/").unwrap();
assert!(!spec.matches_url(Some(&http)), "secure cookie must skip http");
assert!(spec.matches_url(Some(&https)), "secure cookie matches https");
}
#[test]
fn header_cookie_parses_domain_and_path() {
let line = "Set-Cookie: token=zzz; Domain=example.com; Path=/git; Secure";
let spec = parse_cookie_spec(line).expect("parse header cookie");
assert_eq!(spec.name_value, "token=zzz");
assert_eq!(spec.domain.as_deref(), Some("example.com"));
assert_eq!(spec.path.as_deref(), Some("/git"));
assert!(spec.secure);
let sub = url::Url::parse("https://api.example.com/git/info/refs").unwrap();
assert!(spec.matches_url(Some(&sub)));
let wrong_path = url::Url::parse("https://example.com/other").unwrap();
assert!(!spec.matches_url(Some(&wrong_path)));
}
#[test]
fn expired_cookie_does_not_match() {
let spec = CookieSpec {
name_value: "x=1".to_owned(),
domain: None,
include_subdomains: false,
path: None,
secure: false,
expires_at: Some(1), };
let url = url::Url::parse("http://example.com/").unwrap();
assert!(!spec.matches_url(Some(&url)));
}
#[test]
fn extra_header_rules_apply_scoped_and_unscoped() {
let mut cfg = ConfigSet::new();
cfg.add_command_override("http.extraHeader", "X-Global: g")
.unwrap();
cfg.add_command_override("http.https://example.com.extraHeader", "X-Scoped: s")
.unwrap();
let client = UreqHttpClient::from_config(&cfg).expect("from_config");
let scoped = client.extra_headers_for_url("https://example.com/repo.git/info/refs");
assert!(
scoped.iter().any(|(n, v)| n == "X-Global" && v == "g"),
"unscoped header should always apply: {scoped:?}"
);
assert!(
scoped.iter().any(|(n, v)| n == "X-Scoped" && v == "s"),
"URL-scoped header should apply on match: {scoped:?}"
);
let off = client.extra_headers_for_url("https://other.test/repo.git/info/refs");
assert!(off.iter().any(|(n, _)| n == "X-Global"));
assert!(!off.iter().any(|(n, _)| n == "X-Scoped"));
}
#[test]
fn empty_extra_header_resets_list() {
let mut cfg = ConfigSet::new();
cfg.add_command_override("http.extraHeader", "X-One: 1")
.unwrap();
cfg.add_command_override("http.extraHeader", "").unwrap();
cfg.add_command_override("http.extraHeader", "X-Two: 2")
.unwrap();
let client = UreqHttpClient::from_config(&cfg).expect("from_config");
let h = client.extra_headers_for_url("https://example.com/");
assert!(
!h.iter().any(|(n, _)| n == "X-One"),
"empty value must reset earlier headers: {h:?}"
);
assert!(h.iter().any(|(n, v)| n == "X-Two" && v == "2"));
}
#[test]
fn socks_proxy_is_rejected_clearly() {
let mut cfg = ConfigSet::new();
cfg.add_command_override("http.proxy", "socks5://localhost:1080")
.unwrap();
match UreqHttpClient::from_config(&cfg) {
Ok(_) => panic!("SOCKS proxy must be rejected, not silently accepted"),
Err(e) => {
let msg = format!("{e}");
assert!(msg.contains("SOCKS"), "expected a clear SOCKS error: {msg}");
}
}
}
#[test]
fn http_proxy_config_builds() {
let mut cfg = ConfigSet::new();
cfg.add_command_override("http.proxy", "http://127.0.0.1:3128")
.unwrap();
UreqHttpClient::from_config(&cfg).expect("http proxy config builds");
}
#[test]
fn empty_proxy_disables_proxying() {
let mut cfg = ConfigSet::new();
cfg.add_command_override("http.proxy", "").unwrap();
let proxy = build_proxy(&cfg).expect("empty proxy ok");
assert!(proxy.is_none(), "empty http.proxy must disable proxying");
}
}