Skip to main content

deslicer_cli/ci/
github.rs

1use super::{OidcError, OidcTokenProvider};
2
3pub struct GithubProvider;
4
5fn percent_encode_query(value: &str) -> String {
6    let mut encoded = String::with_capacity(value.len());
7    for byte in value.bytes() {
8        match byte {
9            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
10                encoded.push(byte as char);
11            }
12            _ => {
13                encoded.push('%');
14                encoded.push_str(&format!("{byte:02X}"));
15            }
16        }
17    }
18    encoded
19}
20
21fn required_env(name: &str) -> Result<String, OidcError> {
22    std::env::var(name).map_err(|_| OidcError::MissingEnv(name.to_string()))
23}
24
25#[derive(serde::Deserialize)]
26struct GithubTokenResponse {
27    value: String,
28}
29
30#[async_trait::async_trait]
31impl OidcTokenProvider for GithubProvider {
32    async fn fetch_token(&self, audience: &str) -> Result<String, OidcError> {
33        let request_token = required_env("ACTIONS_ID_TOKEN_REQUEST_TOKEN")?;
34        let request_url = required_env("ACTIONS_ID_TOKEN_REQUEST_URL")?;
35
36        let url = format!("{request_url}&audience={}", percent_encode_query(audience));
37
38        let client = reqwest::Client::new();
39        let response = client
40            .get(&url)
41            .header("Authorization", format!("Bearer {request_token}"))
42            .send()
43            .await
44            .map_err(|e| OidcError::Http(e.to_string()))?;
45
46        if !response.status().is_success() {
47            return Err(OidcError::Http(format!(
48                "GitHub OIDC request failed with status {}",
49                response.status()
50            )));
51        }
52
53        let body = response
54            .json::<GithubTokenResponse>()
55            .await
56            .map_err(|e| OidcError::Http(e.to_string()))?;
57
58        Ok(body.value)
59    }
60}
61
62#[cfg(test)]
63// ENV_LOCK only serializes env access across single-threaded #[tokio::test] cases;
64// holding it across the await is safe (no cross-task contention).
65#[allow(clippy::await_holding_lock)]
66mod tests {
67    use super::*;
68    use std::sync::Mutex;
69    use wiremock::matchers::{header, method, query_param};
70    use wiremock::{Mock, MockServer, ResponseTemplate};
71
72    static ENV_LOCK: Mutex<()> = Mutex::new(());
73
74    #[tokio::test]
75    async fn fetch_token_returns_jwt_from_mock_server() {
76        let _guard = ENV_LOCK.lock().unwrap();
77
78        let server = MockServer::start().await;
79        let audience = "https://api.deslicer.ai";
80
81        Mock::given(method("GET"))
82            .and(header("Authorization", "Bearer dummy-request-token"))
83            .and(query_param("audience", audience))
84            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
85                "value": "github-jwt-token"
86            })))
87            .mount(&server)
88            .await;
89
90        std::env::set_var(
91            "ACTIONS_ID_TOKEN_REQUEST_URL",
92            format!("{}?something=1", server.uri()),
93        );
94        std::env::set_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "dummy-request-token");
95
96        let token = GithubProvider.fetch_token(audience).await.unwrap();
97
98        assert_eq!(token, "github-jwt-token");
99
100        std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_URL");
101        std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
102    }
103
104    #[tokio::test]
105    async fn fetch_token_errors_when_env_missing() {
106        let _guard = ENV_LOCK.lock().unwrap();
107
108        std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
109        std::env::remove_var("ACTIONS_ID_TOKEN_REQUEST_URL");
110
111        let err = GithubProvider
112            .fetch_token("https://api.deslicer.ai")
113            .await
114            .unwrap_err();
115
116        assert!(matches!(err, OidcError::MissingEnv(_)));
117    }
118}