use std::sync::Arc;
use anodizer_core::config::GitHubUrlsConfig;
use anyhow::{Context as _, Result};
use http::header::HeaderValue;
use octocrab::service::middleware::auth_header::AuthHeaderLayer;
use octocrab::service::middleware::base_uri::BaseUriLayer;
use octocrab::service::middleware::extra_headers::ExtraHeadersLayer;
use super::secondary_rate_limit::{RetryAfterCapture, RetryAfterLayer};
use crate::release_log;
pub(crate) fn build_octocrab_client(
token: &str,
github_urls: &Option<GitHubUrlsConfig>,
) -> Result<(octocrab::Octocrab, RetryAfterCapture)> {
let skip_tls = github_urls
.as_ref()
.and_then(|u| u.skip_tls_verify)
.unwrap_or(false);
let capture = RetryAfterCapture::new();
let base_uri: http::Uri = if let Some(api) = github_urls.as_ref().and_then(|u| u.api.as_ref()) {
api.parse()
.context("release: invalid github_urls.api URL")?
} else {
"https://api.github.com"
.parse()
.unwrap_or_else(|e| panic!("hardcoded URI is valid: {e}"))
};
let upload_uri: http::Uri =
if let Some(upload) = github_urls.as_ref().and_then(|u| u.upload.as_ref()) {
upload
.parse()
.context("release: invalid github_urls.upload URL")?
} else {
"https://uploads.github.com"
.parse()
.unwrap_or_else(|e| panic!("hardcoded URI is valid: {e}"))
};
let auth_header: HeaderValue = format!("Bearer {}", token)
.parse()
.context("release: format auth header")?;
let retry_layer = RetryAfterLayer::new(capture.clone());
let headers_layer = ExtraHeadersLayer::new(Arc::new(vec![(
http::header::USER_AGENT,
HeaderValue::from_static("octocrab"),
)]));
let base_layer = BaseUriLayer::new(base_uri.clone());
let auth_layer = AuthHeaderLayer::new(Some(auth_header), base_uri, upload_uri);
let octo = if skip_tls {
release_log()
.warn("TLS certificate verification disabled for GitHub API — this is insecure");
let crypto_provider = rustls::crypto::ring::default_provider();
let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(crypto_provider))
.with_safe_default_protocol_versions()
.context("release: configure TLS protocol versions")?
.dangerous()
.with_custom_certificate_verifier(Arc::new(DangerousNoCertVerifier::new()))
.with_no_client_auth();
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_http1()
.build();
let client =
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build(connector);
octocrab::OctocrabBuilder::new_empty()
.with_service(client)
.with_layer(&retry_layer)
.with_layer(&headers_layer)
.with_layer(&base_layer)
.with_layer(&auth_layer)
.with_auth(octocrab::AuthState::None)
.build()
.map_err(|e| match e {})?
} else {
let connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_native_roots()
.expect("release: load native TLS root certificates")
.https_or_http()
.enable_http1()
.build();
let mut timeout = hyper_timeout::TimeoutConnector::new(connector);
timeout.set_connect_timeout(Some(std::time::Duration::from_secs(30)));
timeout.set_read_timeout(Some(std::time::Duration::from_secs(120)));
timeout.set_write_timeout(Some(std::time::Duration::from_secs(120)));
let client =
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build(timeout);
octocrab::OctocrabBuilder::new_empty()
.with_service(client)
.with_layer(&retry_layer)
.with_layer(&headers_layer)
.with_layer(&base_layer)
.with_layer(&auth_layer)
.with_auth(octocrab::AuthState::None)
.build()
.map_err(|e| match e {})?
};
Ok((octo, capture))
}
#[derive(Debug)]
struct DangerousNoCertVerifier {
schemes: Vec<rustls::SignatureScheme>,
}
impl DangerousNoCertVerifier {
fn new() -> Self {
Self {
schemes: rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes(),
}
}
}
impl rustls::client::danger::ServerCertVerifier for DangerousNoCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.schemes.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use anodizer_core::config::GitHubUrlsConfig;
use anodizer_core::test_helpers::responder::spawn_oneshot_http_responder;
use std::time::Duration;
#[tokio::test]
async fn retry_after_header_is_captured_through_the_layer_stack() {
let body = r#"{"message":"You have exceeded a secondary rate limit","documentation_url":"https://docs.github.com/rest/overview/resources-in-the-rest-api#secondary-rate-limits"}"#;
let resp = Box::leak(
format!(
"HTTP/1.1 403 Forbidden\r\n\
Content-Type: application/json\r\n\
Retry-After: 90\r\n\
Content-Length: {}\r\n\
\r\n\
{body}",
body.len()
)
.into_boxed_str(),
);
let (addr, _calls) = spawn_oneshot_http_responder(vec![resp]);
let github_urls = Some(GitHubUrlsConfig {
api: Some(format!("http://{addr}/")),
upload: Some(format!("http://{addr}/")),
download: Some(format!("http://{addr}/")),
skip_tls_verify: None,
});
let (octo, capture) =
build_octocrab_client("test-token", &github_urls).expect("build client");
assert!(
capture.get().is_none(),
"capture must start empty (no response seen yet)"
);
let _ = octo
.get::<serde_json::Value, _, _>("/test", None::<&()>)
.await
.expect_err("403 must surface as an error");
assert_eq!(
capture.get(),
Some(Duration::from_secs(90)),
"RetryAfterLayer must capture `Retry-After: 90` through the full stack"
);
}
}