use crate::auth::types::{AccessToken, CachedToken};
use crate::token::{TokenError, TokenProvider};
use async_trait::async_trait;
const TOKEN_EXPIRY_MARGIN_SECS: u64 = 300;
const TOKEN_LIFETIME_SECS: u64 = 3600;
#[derive(Debug, thiserror::Error)]
pub enum GcloudError {
#[error("gcloud command not found in PATH")]
NotInstalled,
#[error("gcloud command failed: {0}")]
CommandFailed(String),
#[error("gcloud auth failed: {0}")]
AuthFailed(String),
#[error("gcloud returned empty token")]
EmptyToken,
#[error("gcloud returned invalid token format")]
InvalidTokenFormat,
}
#[derive(Debug)]
pub struct GcloudCredential {
quota_project_id: Option<String>,
cached_token: CachedToken,
}
impl GcloudCredential {
#[allow(dead_code)]
fn check_gcloud_installed() -> Result<(), GcloudError> {
Self::check_gcloud_installed_impl("gcloud")
}
fn check_gcloud_installed_impl(command: &str) -> Result<(), GcloudError> {
which::which(command).map_err(|e| {
tracing::debug!("gcloud command '{}' not found in PATH: {:?}", command, e);
GcloudError::NotInstalled
})?;
Ok(())
}
async fn get_access_token() -> Result<String, GcloudError> {
Self::get_access_token_impl("gcloud").await
}
async fn get_access_token_impl(command: &str) -> Result<String, GcloudError> {
Self::get_access_token_impl_with_args(command, &["auth", "print-access-token", "--quiet"])
.await
}
async fn get_access_token_impl_with_args(
command: &str,
args: &[&str],
) -> Result<String, GcloudError> {
let output = tokio::process::Command::new(command)
.args(args)
.output()
.await
.map_err(|e| GcloudError::CommandFailed(e.to_string()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GcloudError::AuthFailed(stderr.to_string()));
}
let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
if token.is_empty() {
return Err(GcloudError::EmptyToken);
}
Ok(token)
}
#[allow(dead_code)]
async fn get_quota_project() -> Result<String, GcloudError> {
Self::get_quota_project_impl("gcloud").await
}
async fn get_quota_project_impl(command: &str) -> Result<String, GcloudError> {
Self::get_quota_project_impl_with_args(
command,
&["config", "get-value", "project", "--quiet"],
)
.await
}
async fn get_quota_project_impl_with_args(
command: &str,
args: &[&str],
) -> Result<String, GcloudError> {
let output = tokio::process::Command::new(command)
.args(args)
.output()
.await
.map_err(|e| GcloudError::CommandFailed(e.to_string()))?;
if !output.status.success() {
return Err(GcloudError::CommandFailed(
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
let project = String::from_utf8_lossy(&output.stdout).trim().to_string();
if project.is_empty() {
return Err(GcloudError::CommandFailed(
"no project configured".to_string(),
));
}
Ok(project)
}
pub async fn new() -> Result<Self, GcloudError> {
Self::new_impl("gcloud").await
}
async fn new_impl(command: &str) -> Result<Self, GcloudError> {
Self::check_gcloud_installed_impl(command)?;
let token = Self::get_access_token_impl(command).await?;
let quota_project_id = Self::get_quota_project_impl(command).await.ok();
let cached_token = CachedToken::new();
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
+ TOKEN_LIFETIME_SECS;
cached_token.set(AccessToken::new(token, expires_at)).await;
Ok(Self {
quota_project_id,
cached_token,
})
}
}
#[async_trait]
impl TokenProvider for GcloudCredential {
async fn get_token(&self, _scopes: &[&str]) -> Result<String, TokenError> {
if let Some(token) = self.cached_token.get(TOKEN_EXPIRY_MARGIN_SECS).await {
return Ok(token);
}
let token = Self::get_access_token()
.await
.map_err(|e| TokenError::RefreshFailed {
message: e.to_string(),
})?;
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
+ TOKEN_LIFETIME_SECS;
self.cached_token
.set(AccessToken::new(token.clone(), expires_at))
.await;
Ok(token)
}
fn on_token_rejected(&self) {
self.cached_token.clear_sync();
}
fn quota_project_id(&self) -> Option<&str> {
self.quota_project_id.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gcloud_error_display() {
let err = GcloudError::NotInstalled;
assert_eq!(err.to_string(), "gcloud command not found in PATH");
let err = GcloudError::CommandFailed("exec error".to_string());
assert!(err.to_string().contains("gcloud command failed"));
assert!(err.to_string().contains("exec error"));
let err = GcloudError::AuthFailed("not logged in".to_string());
assert!(err.to_string().contains("gcloud auth failed"));
assert!(err.to_string().contains("not logged in"));
let err = GcloudError::EmptyToken;
assert_eq!(err.to_string(), "gcloud returned empty token");
let err = GcloudError::InvalidTokenFormat;
assert_eq!(err.to_string(), "gcloud returned invalid token format");
}
#[test]
fn test_check_gcloud_installed_not_found() {
let result = GcloudCredential::check_gcloud_installed_impl("/nonexistent/gcloud");
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), GcloudError::NotInstalled));
}
#[test]
fn test_check_gcloud_installed_success() {
let result = GcloudCredential::check_gcloud_installed_impl("ls");
assert!(result.is_ok());
}
#[tokio::test]
async fn test_get_access_token_command_fails() {
let result = GcloudCredential::get_access_token_impl("false").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), GcloudError::AuthFailed(_)));
}
#[tokio::test]
async fn test_get_access_token_empty_output() {
let result = GcloudCredential::get_access_token_impl_with_args("printf", &[""]).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), GcloudError::EmptyToken));
}
#[tokio::test]
async fn test_get_access_token_success() {
let result =
GcloudCredential::get_access_token_impl_with_args("echo", &["test-token"]).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "test-token");
}
#[tokio::test]
async fn test_get_quota_project_success() {
let result =
GcloudCredential::get_quota_project_impl_with_args("echo", &["my-project"]).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "my-project");
}
#[tokio::test]
async fn test_get_quota_project_empty() {
let result = GcloudCredential::get_quota_project_impl_with_args("printf", &[""]).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), GcloudError::CommandFailed(_)));
}
#[tokio::test]
async fn test_get_quota_project_command_fails() {
let result = GcloudCredential::get_quota_project_impl("false").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_gcloud_credential_new_not_installed() {
let result = GcloudCredential::new_impl("nonexistent-command").await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), GcloudError::NotInstalled));
}
#[tokio::test]
async fn test_token_provider_get_token() {
let cred = GcloudCredential {
quota_project_id: Some("test-project".to_string()),
cached_token: CachedToken::new(),
};
assert_eq!(cred.quota_project_id(), Some("test-project"));
}
#[test]
fn test_token_provider_quota_project_none() {
let cred = GcloudCredential {
quota_project_id: None,
cached_token: CachedToken::new(),
};
assert_eq!(cred.quota_project_id(), None);
}
#[tokio::test]
async fn test_on_token_rejected_clears_cache() {
use crate::auth::types::AccessToken;
let cred = GcloudCredential {
quota_project_id: None,
cached_token: CachedToken::new(),
};
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
+ 3600;
cred.cached_token
.set(AccessToken::new("test-token", expires_at))
.await;
cred.on_token_rejected();
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
assert!(cred.cached_token.get(0).await.is_none());
}
#[tokio::test]
async fn test_token_caching() {
use crate::auth::types::AccessToken;
let cred = GcloudCredential {
quota_project_id: None,
cached_token: CachedToken::new(),
};
let expires_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
+ 3600; cred.cached_token
.set(AccessToken::new("cached-token", expires_at))
.await;
let result = cred.get_token(&[]).await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "cached-token");
cred.on_token_rejected();
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
assert!(cred.cached_token.get(0).await.is_none());
}
}