use std::future::Future;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::time::sleep;
use auths_core::ports::platform::{PlatformError, SshSigningKeyUploader};
use crate::default_http_client;
use crate::error::map_reqwest_error;
#[derive(Deserialize, Debug)]
struct SshKeyResponse {
id: u64,
key: String,
#[serde(default)]
#[allow(dead_code)]
title: String,
#[allow(dead_code)]
verified: bool,
}
#[derive(Serialize)]
struct CreateSshKeyRequest {
key: String,
title: String,
}
pub struct HttpGitHubSshKeyUploader {
client: reqwest::Client,
}
impl HttpGitHubSshKeyUploader {
pub fn new() -> Self {
Self {
client: default_http_client(),
}
}
}
impl Default for HttpGitHubSshKeyUploader {
fn default() -> Self {
Self::new()
}
}
impl SshSigningKeyUploader for HttpGitHubSshKeyUploader {
fn upload_signing_key(
&self,
access_token: &str,
public_key: &str,
title: &str,
) -> impl Future<Output = Result<String, PlatformError>> + Send {
let client = self.client.clone();
let access_token = access_token.to_string();
let public_key = public_key.to_string();
let title = title.to_string();
async move { upload_signing_key_impl(&client, &access_token, &public_key, &title).await }
}
}
async fn upload_signing_key_impl(
client: &reqwest::Client,
access_token: &str,
public_key: &str,
title: &str,
) -> Result<String, PlatformError> {
if let Ok(existing_id) = check_existing_key(client, access_token, public_key).await {
return Ok(existing_id);
}
post_ssh_key_with_retry(client, access_token, public_key, title).await
}
async fn check_existing_key(
client: &reqwest::Client,
access_token: &str,
public_key: &str,
) -> Result<String, PlatformError> {
let resp = client
.get("https://api.github.com/user/ssh_signing_keys")
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "auths-cli")
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| PlatformError::Network(map_reqwest_error(e, "api.github.com")))?;
let status = resp.status().as_u16();
if status == 401 {
return Err(PlatformError::Platform {
message: "GitHub authentication failed. Check your token and try again.".to_string(),
});
}
if status == 403 {
return Err(PlatformError::Platform {
message:
"Insufficient GitHub scope. Run 'auths id update-scope github' to re-authorize."
.to_string(),
});
}
if !resp.status().is_success() {
return Err(PlatformError::Network(
auths_core::ports::network::NetworkError::InvalidResponse {
detail: format!("HTTP {}", status),
},
));
}
let keys: Vec<SshKeyResponse> = resp.json().await.map_err(|e| PlatformError::Platform {
message: format!("failed to parse SSH keys response: {e}"),
})?;
for key in keys {
if key.key == public_key {
return Ok(key.id.to_string());
}
}
Err(PlatformError::Platform {
message: "key not found".to_string(),
})
}
async fn post_ssh_key_with_retry(
client: &reqwest::Client,
access_token: &str,
public_key: &str,
title: &str,
) -> Result<String, PlatformError> {
const MAX_RETRIES: u32 = 3;
let mut attempt = 0;
loop {
attempt += 1;
let backoff_secs = if attempt > 1 {
2_u64.pow(attempt - 2)
} else {
0
};
if attempt > 1 {
let jitter_ms = (rand::random::<u64>() % (backoff_secs * 1000 / 2)) as u64;
let delay = Duration::from_secs(backoff_secs) + Duration::from_millis(jitter_ms);
sleep(delay).await;
}
let payload = CreateSshKeyRequest {
key: public_key.to_string(),
title: title.to_string(),
};
let resp = client
.post("https://api.github.com/user/ssh_signing_keys")
.header("Authorization", format!("Bearer {}", access_token))
.header("User-Agent", "auths-cli")
.header("Accept", "application/vnd.github+json")
.json(&payload)
.send()
.await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
let net_err = map_reqwest_error(e, "api.github.com");
if attempt < MAX_RETRIES {
continue;
}
return Err(PlatformError::Network(net_err));
}
};
let status = resp.status().as_u16();
if status == 201 {
match resp.json::<SshKeyResponse>().await {
Ok(key) => return Ok(key.id.to_string()),
Err(_e) => {
return Ok("created".to_string());
}
}
}
if status == 422 {
return Ok("duplicate".to_string());
}
if status == 401 {
return Err(PlatformError::Platform {
message: "GitHub authentication failed. Check your token and try again."
.to_string(),
});
}
if status == 403 {
return Err(PlatformError::Platform {
message:
"Insufficient GitHub scope. Run 'auths id update-scope github' to re-authorize."
.to_string(),
});
}
if status == 429 {
if let Some(retry_after) = resp.headers().get("retry-after")
&& let Ok(retry_str) = retry_after.to_str()
&& let Ok(retry_secs) = retry_str.parse::<u64>()
{
sleep(Duration::from_secs(retry_secs)).await;
continue;
}
if attempt < MAX_RETRIES {
continue;
}
return Err(PlatformError::Platform {
message: "GitHub rate limit exceeded. Try again later.".to_string(),
});
}
if (500..600).contains(&status) {
if attempt < MAX_RETRIES {
continue;
}
return Err(PlatformError::Platform {
message: format!("GitHub service error (HTTP {status}). Try again later."),
});
}
let body = resp.text().await.unwrap_or_default();
return Err(PlatformError::Platform {
message: format!("SSH key upload failed (HTTP {status}): {body}"),
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uploader_constructs() {
let _uploader = HttpGitHubSshKeyUploader::new();
}
#[test]
fn upload_signing_key_returns_key_id_on_201() {
let _uploader = HttpGitHubSshKeyUploader::new();
let access_token = "test_token";
let public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHK5hkxLPKx6KLwlzQ";
let title = "test/key";
assert!(!access_token.is_empty());
assert!(!public_key.is_empty());
assert!(!title.is_empty());
}
#[test]
fn ssh_key_response_deserializes() {
let json =
r#"{"id": 12345, "key": "ssh-ed25519 AAAA...", "title": "test-key", "verified": true}"#;
let key: Result<SshKeyResponse, _> = serde_json::from_str(json);
assert!(key.is_ok());
let key = key.unwrap();
assert_eq!(key.id, 12345);
}
#[test]
fn create_ssh_key_request_serializes() {
let req = CreateSshKeyRequest {
key: "ssh-ed25519 AAAA...".to_string(),
title: "test".to_string(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("ssh-ed25519"));
assert!(json.contains("test"));
}
}