Skip to main content

auths_infra_http/
github_ssh_keys.rs

1//! GitHub SSH signing key uploader HTTP implementation.
2
3use std::future::Future;
4use std::time::Duration;
5
6use serde::{Deserialize, Serialize};
7use tokio::time::sleep;
8
9use auths_core::ports::platform::{PlatformError, SshSigningKeyUploader};
10
11use crate::default_http_client;
12use crate::error::map_reqwest_error;
13
14#[derive(Deserialize, Debug)]
15struct SshKeyResponse {
16    id: u64,
17    key: String,
18    #[serde(default)]
19    #[allow(dead_code)]
20    title: String,
21    #[allow(dead_code)]
22    verified: bool,
23}
24
25#[derive(Serialize)]
26struct CreateSshKeyRequest {
27    key: String,
28    title: String,
29}
30
31/// HTTP implementation that uploads SSH signing keys to GitHub for commit verification.
32///
33/// Performs pre-flight duplicate detection before uploading, handles authentication
34/// failures and rate limiting gracefully, and retries transient errors with exponential backoff.
35///
36/// Usage:
37/// ```ignore
38/// let uploader = HttpGitHubSshKeyUploader::new();
39/// let key_id = uploader.upload_signing_key(&token, &public_key, "auths/main").await?;
40/// ```
41pub struct HttpGitHubSshKeyUploader {
42    client: reqwest::Client,
43}
44
45impl HttpGitHubSshKeyUploader {
46    /// Create a new uploader with a default HTTP client.
47    pub fn new() -> Self {
48        Self {
49            client: default_http_client(),
50        }
51    }
52}
53
54impl Default for HttpGitHubSshKeyUploader {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl SshSigningKeyUploader for HttpGitHubSshKeyUploader {
61    fn upload_signing_key(
62        &self,
63        access_token: &str,
64        public_key: &str,
65        title: &str,
66    ) -> impl Future<Output = Result<String, PlatformError>> + Send {
67        let client = self.client.clone();
68        let access_token = access_token.to_string();
69        let public_key = public_key.to_string();
70        let title = title.to_string();
71
72        async move { upload_signing_key_impl(&client, &access_token, &public_key, &title).await }
73    }
74}
75
76async fn upload_signing_key_impl(
77    client: &reqwest::Client,
78    access_token: &str,
79    public_key: &str,
80    title: &str,
81) -> Result<String, PlatformError> {
82    // Pre-flight: check for existing key to avoid duplicate errors
83    if let Ok(existing_id) = check_existing_key(client, access_token, public_key).await {
84        return Ok(existing_id);
85    }
86
87    // POST new key with exponential backoff retry logic
88    post_ssh_key_with_retry(client, access_token, public_key, title).await
89}
90
91async fn check_existing_key(
92    client: &reqwest::Client,
93    access_token: &str,
94    public_key: &str,
95) -> Result<String, PlatformError> {
96    let resp = client
97        .get("https://api.github.com/user/ssh_signing_keys")
98        .header("Authorization", format!("Bearer {}", access_token))
99        .header("User-Agent", "auths-cli")
100        .header("Accept", "application/vnd.github+json")
101        .send()
102        .await
103        .map_err(|e| PlatformError::Network(map_reqwest_error(e, "api.github.com")))?;
104
105    let status = resp.status().as_u16();
106    if status == 401 {
107        return Err(PlatformError::Platform {
108            message: "GitHub authentication failed. Check your token and try again.".to_string(),
109        });
110    }
111    if status == 403 {
112        return Err(PlatformError::Platform {
113            message:
114                "Insufficient GitHub scope. Run 'auths id update-scope github' to re-authorize."
115                    .to_string(),
116        });
117    }
118    if !resp.status().is_success() {
119        return Err(PlatformError::Network(
120            auths_core::ports::network::NetworkError::InvalidResponse {
121                detail: format!("HTTP {}", status),
122            },
123        ));
124    }
125
126    let keys: Vec<SshKeyResponse> = resp.json().await.map_err(|e| PlatformError::Platform {
127        message: format!("failed to parse SSH keys response: {e}"),
128    })?;
129
130    // Check for exact key match or fingerprint match
131    for key in keys {
132        if key.key == public_key {
133            return Ok(key.id.to_string());
134        }
135    }
136
137    Err(PlatformError::Platform {
138        message: "key not found".to_string(),
139    })
140}
141
142async fn post_ssh_key_with_retry(
143    client: &reqwest::Client,
144    access_token: &str,
145    public_key: &str,
146    title: &str,
147) -> Result<String, PlatformError> {
148    const MAX_RETRIES: u32 = 3;
149    let mut attempt = 0;
150
151    loop {
152        attempt += 1;
153        let backoff_secs = if attempt > 1 {
154            2_u64.pow(attempt - 2)
155        } else {
156            0
157        };
158
159        if attempt > 1 {
160            let jitter_ms = (rand::random::<u64>() % (backoff_secs * 1000 / 2)) as u64;
161            let delay = Duration::from_secs(backoff_secs) + Duration::from_millis(jitter_ms);
162            sleep(delay).await;
163        }
164
165        let payload = CreateSshKeyRequest {
166            key: public_key.to_string(),
167            title: title.to_string(),
168        };
169
170        let resp = client
171            .post("https://api.github.com/user/ssh_signing_keys")
172            .header("Authorization", format!("Bearer {}", access_token))
173            .header("User-Agent", "auths-cli")
174            .header("Accept", "application/vnd.github+json")
175            .json(&payload)
176            .send()
177            .await;
178
179        let resp = match resp {
180            Ok(r) => r,
181            Err(e) => {
182                let net_err = map_reqwest_error(e, "api.github.com");
183                if attempt < MAX_RETRIES {
184                    continue;
185                }
186                return Err(PlatformError::Network(net_err));
187            }
188        };
189
190        let status = resp.status().as_u16();
191
192        // Success: key created
193        if status == 201 {
194            match resp.json::<SshKeyResponse>().await {
195                Ok(key) => return Ok(key.id.to_string()),
196                Err(_e) => {
197                    // If deserialization fails but we got 201, the key was created.
198                    // Return a placeholder - metadata storage will verify it worked.
199                    return Ok("created".to_string());
200                }
201            }
202        }
203
204        // 422: Unprocessable Entity - likely duplicate, treat as success
205        if status == 422 {
206            return Ok("duplicate".to_string());
207        }
208
209        // 401: Unauthorized
210        if status == 401 {
211            return Err(PlatformError::Platform {
212                message: "GitHub authentication failed. Check your token and try again."
213                    .to_string(),
214            });
215        }
216
217        // 403: Forbidden - likely missing scope
218        if status == 403 {
219            return Err(PlatformError::Platform {
220                message:
221                    "Insufficient GitHub scope. Run 'auths id update-scope github' to re-authorize."
222                        .to_string(),
223            });
224        }
225
226        // 429: Rate limited - respect Retry-After header
227        if status == 429 {
228            if let Some(retry_after) = resp.headers().get("retry-after")
229                && let Ok(retry_str) = retry_after.to_str()
230                && let Ok(retry_secs) = retry_str.parse::<u64>()
231            {
232                sleep(Duration::from_secs(retry_secs)).await;
233                continue;
234            }
235            if attempt < MAX_RETRIES {
236                continue;
237            }
238            return Err(PlatformError::Platform {
239                message: "GitHub rate limit exceeded. Try again later.".to_string(),
240            });
241        }
242
243        // 5xx: Server error - retry
244        if (500..600).contains(&status) {
245            if attempt < MAX_RETRIES {
246                continue;
247            }
248            return Err(PlatformError::Platform {
249                message: format!("GitHub service error (HTTP {status}). Try again later."),
250            });
251        }
252
253        // Any other status: error
254        let body = resp.text().await.unwrap_or_default();
255        return Err(PlatformError::Platform {
256            message: format!("SSH key upload failed (HTTP {status}): {body}"),
257        });
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn uploader_constructs() {
267        let _uploader = HttpGitHubSshKeyUploader::new();
268    }
269
270    #[test]
271    fn upload_signing_key_returns_key_id_on_201() {
272        let _uploader = HttpGitHubSshKeyUploader::new();
273
274        let access_token = "test_token";
275        let public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHK5hkxLPKx6KLwlzQ";
276        let title = "test/key";
277
278        // This test validates that the uploader constructs successfully.
279        // Full async tests with mocking would be in integration tests.
280        assert!(!access_token.is_empty());
281        assert!(!public_key.is_empty());
282        assert!(!title.is_empty());
283    }
284
285    #[test]
286    fn ssh_key_response_deserializes() {
287        let json =
288            r#"{"id": 12345, "key": "ssh-ed25519 AAAA...", "title": "test-key", "verified": true}"#;
289        let key: Result<SshKeyResponse, _> = serde_json::from_str(json);
290        assert!(key.is_ok());
291        let key = key.unwrap();
292        assert_eq!(key.id, 12345);
293    }
294
295    #[test]
296    fn create_ssh_key_request_serializes() {
297        let req = CreateSshKeyRequest {
298            key: "ssh-ed25519 AAAA...".to_string(),
299            title: "test".to_string(),
300        };
301        let json = serde_json::to_string(&req).unwrap();
302        assert!(json.contains("ssh-ed25519"));
303        assert!(json.contains("test"));
304    }
305}