ghpending 0.3.4

CLI to watch GitHub repos for open issues and pull requests at a glance
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::Duration;

use anyhow::{Context, Result, bail};
use http::{HeaderValue, Uri, header::AUTHORIZATION, header::USER_AGENT};
use hyper_socks2::SocksConnector;
use hyper_util::{
    client::legacy::{Client, connect::HttpConnector},
    rt::TokioExecutor,
};
use octocrab::service::middleware::base_uri::BaseUriLayer;
use octocrab::service::middleware::extra_headers::ExtraHeadersLayer;
use octocrab::{AuthState, OctoBody, Octocrab, OctocrabBuilder};

const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const READ_TIMEOUT: Duration = Duration::from_secs(30);
const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
const PROXY_CHECK_TIMEOUT: Duration = Duration::from_millis(250);

const GITHUB_BASE_URI: &str = "https://api.github.com";
const DEFAULT_GITHUB_PROXY_ADDR: &str = "127.0.0.1:9050";
const DEFAULT_GITHUB_PROXY_URI: &str = "socks5://127.0.0.1:9050";

pub fn build() -> Result<Octocrab> {
    let token = github_token();

    if let Some(proxy) = proxy_from_environment_or_local_port()? {
        match build_proxied(token.as_deref(), proxy.uri) {
            Ok(crab) => return Ok(crab),
            Err(err) if proxy.strict => return Err(err),
            Err(_) => {}
        }
    }

    build_direct(token)
}

fn build_direct(token: Option<String>) -> Result<Octocrab> {
    let mut builder = OctocrabBuilder::default()
        .set_connect_timeout(Some(web_time::Duration::from_secs(
            CONNECT_TIMEOUT.as_secs(),
        )))
        .set_read_timeout(Some(web_time::Duration::from_secs(READ_TIMEOUT.as_secs())))
        .set_write_timeout(Some(web_time::Duration::from_secs(WRITE_TIMEOUT.as_secs())));
    if let Some(token) = token {
        builder = builder.personal_token(token);
    }
    Ok(builder.build()?)
}

fn build_proxied(token: Option<&str>, proxy_uri: Uri) -> Result<Octocrab> {
    let mut connector = HttpConnector::new();
    connector.enforce_http(false);

    let socks = SocksConnector {
        proxy_addr: proxy_uri,
        auth: None,
        connector,
    };
    let https = socks.with_tls().context("creating SOCKS TLS connector")?;

    let timeout_builder = OctocrabBuilder::default()
        .set_connect_timeout(Some(web_time::Duration::from_secs(
            CONNECT_TIMEOUT.as_secs(),
        )))
        .set_read_timeout(Some(web_time::Duration::from_secs(READ_TIMEOUT.as_secs())))
        .set_write_timeout(Some(web_time::Duration::from_secs(WRITE_TIMEOUT.as_secs())));
    let connector = timeout_builder.set_connect_timeout_service(https);
    let client = Client::builder(TokioExecutor::new()).build::<_, OctoBody>(connector);

    let mut headers = vec![(USER_AGENT, HeaderValue::from_static("ghpending"))];
    if let Some(token) = token {
        let mut value = HeaderValue::from_str(&format!("Bearer {token}"))
            .context("building GitHub authorization header")?;
        value.set_sensitive(true);
        headers.push((AUTHORIZATION, value));
    }

    Ok(OctocrabBuilder::new_empty()
        .with_service(client)
        .with_layer(&BaseUriLayer::new(Uri::from_static(GITHUB_BASE_URI)))
        .with_layer(&ExtraHeadersLayer::new(Arc::new(headers)))
        .with_auth(AuthState::None)
        .build()?)
}

fn github_token() -> Option<String> {
    std::env::var("GITHUB_TOKEN")
        .ok()
        .map(|token| token.trim().to_owned())
        .filter(|token| !token.is_empty())
}

#[derive(Debug)]
struct ProxyChoice {
    uri: Uri,
    strict: bool,
}

fn proxy_from_environment_or_local_port() -> Result<Option<ProxyChoice>> {
    if let Some(raw) = std::env::var("GHPENDING_GITHUB_PROXY").ok() {
        return Ok(Some(ProxyChoice {
            uri: normalize_required_socks_proxy_uri("GHPENDING_GITHUB_PROXY", raw.trim())?,
            strict: true,
        }));
    }

    for key in ["HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"] {
        if let Some(uri) = proxy_uri_from_optional_env_value(key, std::env::var(key).ok())? {
            return Ok(Some(ProxyChoice { uri, strict: true }));
        }
    }

    if local_proxy_is_listening() {
        return Ok(Some(ProxyChoice {
            uri: Uri::from_static(DEFAULT_GITHUB_PROXY_URI),
            strict: false,
        }));
    }

    Ok(None)
}

fn proxy_uri_from_optional_env_value(key: &str, raw: Option<String>) -> Result<Option<Uri>> {
    let Some(raw) = raw else {
        return Ok(None);
    };
    let raw = raw.trim();
    if raw.is_empty() {
        return Ok(None);
    }

    let Ok(uri) = raw.parse::<Uri>() else {
        return Ok(None);
    };
    match uri.scheme_str() {
        Some("socks5") | Some("socks5h") => Ok(Some(normalize_socks_proxy_uri(key, uri)?)),
        _ => Ok(None),
    }
}

fn normalize_required_socks_proxy_uri(key: &str, raw: &str) -> Result<Uri> {
    let uri: Uri = raw
        .parse()
        .with_context(|| format!("{key} must be a valid SOCKS proxy URI"))?;
    match uri.scheme_str() {
        Some("socks5") | Some("socks5h") => normalize_socks_proxy_uri(key, uri),
        _ => bail!("{key} must use socks5:// or socks5h://"),
    }
}

fn normalize_socks_proxy_uri(key: &str, uri: Uri) -> Result<Uri> {
    let authority = uri
        .authority()
        .with_context(|| format!("{key} must include a proxy host"))?
        .as_str();
    if authority.contains('@') {
        bail!("{key} SOCKS proxy authentication is not supported");
    }
    Ok(format!("socks5://{authority}").parse()?)
}

fn local_proxy_is_listening() -> bool {
    let Ok(addr) = DEFAULT_GITHUB_PROXY_ADDR.parse::<SocketAddr>() else {
        return false;
    };
    TcpStream::connect_timeout(&addr, PROXY_CHECK_TIMEOUT).is_ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalizes_socks5h_proxy_uri_for_connector() {
        let uri =
            normalize_required_socks_proxy_uri("TEST_PROXY", "socks5h://127.0.0.1:9050").unwrap();
        assert_eq!(uri.scheme_str(), Some("socks5"));
        assert_eq!(uri.authority().map(|a| a.as_str()), Some("127.0.0.1:9050"));
    }

    #[test]
    fn accepts_socks5_proxy_uri() {
        let uri =
            normalize_required_socks_proxy_uri("TEST_PROXY", "socks5://localhost:1080").unwrap();
        assert_eq!(uri.scheme_str(), Some("socks5"));
        assert_eq!(uri.authority().map(|a| a.as_str()), Some("localhost:1080"));
    }

    #[test]
    fn optional_proxy_env_ignores_http_proxy_uri() {
        assert!(
            proxy_uri_from_optional_env_value("HTTPS_PROXY", Some("http://127.0.0.1:8080".into()))
                .unwrap()
                .is_none()
        );
        assert!(
            proxy_uri_from_optional_env_value("HTTPS_PROXY", Some("https://127.0.0.1:8080".into()))
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn optional_proxy_env_ignores_empty_or_invalid_proxy_uri() {
        assert!(
            proxy_uri_from_optional_env_value("HTTPS_PROXY", Some("".into()))
                .unwrap()
                .is_none()
        );
        assert!(
            proxy_uri_from_optional_env_value("HTTPS_PROXY", Some("not a uri".into()))
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn required_proxy_rejects_http_proxy_uri() {
        assert!(normalize_required_socks_proxy_uri("TEST_PROXY", "http://127.0.0.1:8080").is_err());
    }

    #[test]
    fn rejects_socks_proxy_with_credentials() {
        assert!(
            normalize_required_socks_proxy_uri("TEST_PROXY", "socks5h://user:pass@127.0.0.1:9050")
                .is_err()
        );
    }
}