use async_trait::async_trait;
use serde::Deserialize;
use std::sync::Arc;
use super::types::map_gcp_http_error;
use crate::common::{Error, Result, error::StringError};
const SECRET_MANAGER_BASE: &str = "https://secretmanager.googleapis.com/v1";
const GCP_SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform";
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait GcpSmOps: Send + Sync {
fn display_name(&self) -> String;
fn debug_info(&self) -> String;
async fn get(&self, name: &str) -> Result<String>;
async fn create(&self, name: &str, value: &str) -> Result<()>;
async fn update(&self, name: &str, value: &str) -> Result<()>;
async fn delete(&self, name: &str) -> Result<()>;
async fn list(&self, prefix: Option<String>) -> Result<Vec<String>>;
}
#[derive(Deserialize)]
struct AccessSecretResponse {
payload: SecretPayload,
}
#[derive(Deserialize)]
struct SecretPayload {
data: String, }
#[derive(Deserialize)]
struct ListSecretsResponse {
secrets: Option<Vec<SecretEntry>>,
#[serde(rename = "nextPageToken")]
next_page_token: Option<String>,
}
#[derive(Deserialize)]
struct SecretEntry {
name: String, }
pub(super) struct GcpHttpClient {
pub project_id: String,
pub auth: Arc<dyn gcp_auth::TokenProvider>,
pub http: reqwest::Client,
}
impl GcpHttpClient {
async fn token(&self) -> Result<String> {
self.auth
.token(&[GCP_SCOPE])
.await
.map(|t| t.as_str().to_owned())
.map_err(|e| Error::Unauthenticated {
source: Box::new(e),
})
}
fn secret_path(&self, name: &str) -> String {
format!(
"{}/projects/{}/secrets/{}",
SECRET_MANAGER_BASE, self.project_id, name
)
}
fn version_path(&self, name: &str, version: &str) -> String {
format!("{}/versions/{}", self.secret_path(name), version)
}
}
#[async_trait]
impl GcpSmOps for GcpHttpClient {
fn display_name(&self) -> String {
format!("GcpSecretManager(project={})", self.project_id)
}
fn debug_info(&self) -> String {
format!(
"project_id={project_id}, api={api}, provider=GcpSecretManager",
project_id = self.project_id,
api = SECRET_MANAGER_BASE,
)
}
async fn get(&self, name: &str) -> Result<String> {
let token = self.token().await?;
let url = format!("{}:access", self.version_path(name, "latest"));
let resp = self
.http
.get(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| Error::Generic {
store: "GcpSecretManager",
source: Box::new(e),
})?;
let status = resp.status().as_u16();
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(map_gcp_http_error(name, status, StringError(body)));
}
let parsed: AccessSecretResponse = resp.json().await.map_err(|e| Error::Generic {
store: "GcpSecretManager",
source: Box::new(e),
})?;
let decoded = base64_decode(&parsed.payload.data)?;
String::from_utf8(decoded).map_err(|e| Error::Generic {
store: "GcpSecretManager",
source: Box::new(e),
})
}
async fn create(&self, name: &str, value: &str) -> Result<()> {
let token = self.token().await?;
let create_url = format!(
"{}/projects/{}/secrets?secretId={}",
SECRET_MANAGER_BASE, self.project_id, name
);
let body = serde_json::json!({ "replication": { "automatic": {} } });
let resp = self
.http
.post(&create_url)
.bearer_auth(&token)
.json(&body)
.send()
.await
.map_err(|e| Error::Generic {
store: "GcpSecretManager",
source: Box::new(e),
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let msg = resp.text().await.unwrap_or_default();
return Err(map_gcp_http_error(name, status, StringError(msg)));
}
self.update(name, value).await
}
async fn update(&self, name: &str, value: &str) -> Result<()> {
let token = self.token().await?;
let url = format!("{}:addVersion", self.secret_path(name));
let encoded = base64_encode(value.as_bytes());
let body = serde_json::json!({ "payload": { "data": encoded } });
let resp = self
.http
.post(&url)
.bearer_auth(&token)
.json(&body)
.send()
.await
.map_err(|e| Error::Generic {
store: "GcpSecretManager",
source: Box::new(e),
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let msg = resp.text().await.unwrap_or_default();
return Err(map_gcp_http_error(name, status, StringError(msg)));
}
Ok(())
}
async fn delete(&self, name: &str) -> Result<()> {
let token = self.token().await?;
let url = self.secret_path(name);
let resp = self
.http
.delete(&url)
.bearer_auth(&token)
.send()
.await
.map_err(|e| Error::Generic {
store: "GcpSecretManager",
source: Box::new(e),
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let msg = resp.text().await.unwrap_or_default();
return Err(map_gcp_http_error(name, status, StringError(msg)));
}
Ok(())
}
async fn list(&self, prefix: Option<String>) -> Result<Vec<String>> {
let token = self.token().await?;
let base_url = format!(
"{}/projects/{}/secrets",
SECRET_MANAGER_BASE, self.project_id
);
let mut names = Vec::new();
let mut page_token: Option<String> = None;
loop {
let mut req = self.http.get(&base_url).bearer_auth(&token);
if let Some(ref pt) = page_token {
req = req.query(&[("pageToken", pt.as_str())]);
}
if let Some(ref p) = prefix {
req = req.query(&[("filter", format!("name:{p}").as_str())]);
}
let resp = req.send().await.map_err(|e| Error::Generic {
store: "GcpSecretManager",
source: Box::new(e),
})?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let msg = resp.text().await.unwrap_or_default();
return Err(map_gcp_http_error("(list)", status, StringError(msg)));
}
let page: ListSecretsResponse = resp.json().await.map_err(|e| Error::Generic {
store: "GcpSecretManager",
source: Box::new(e),
})?;
for entry in page.secrets.unwrap_or_default() {
let short = entry
.name
.split('/')
.next_back()
.unwrap_or(&entry.name)
.to_owned();
names.push(short);
}
match page.next_page_token {
Some(pt) if !pt.is_empty() => page_token = Some(pt),
_ => break,
}
}
Ok(names)
}
}
pub(super) fn base64_encode(data: &[u8]) -> String {
use std::fmt::Write;
let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b0 = chunk[0] as usize;
let b1 = if chunk.len() > 1 {
chunk[1] as usize
} else {
0
};
let b2 = if chunk.len() > 2 {
chunk[2] as usize
} else {
0
};
let _ = write!(out, "{}", alphabet[b0 >> 2] as char);
let _ = write!(out, "{}", alphabet[((b0 & 3) << 4) | (b1 >> 4)] as char);
if chunk.len() > 1 {
let _ = write!(out, "{}", alphabet[((b1 & 0xF) << 2) | (b2 >> 6)] as char);
} else {
out.push('=');
}
if chunk.len() > 2 {
let _ = write!(out, "{}", alphabet[b2 & 0x3F] as char);
} else {
out.push('=');
}
}
out
}
pub(super) fn base64_decode(s: &str) -> Result<Vec<u8>> {
fn val(c: u8) -> Result<u8> {
match c {
b'A'..=b'Z' => Ok(c - b'A'),
b'a'..=b'z' => Ok(c - b'a' + 26),
b'0'..=b'9' => Ok(c - b'0' + 52),
b'+' => Ok(62),
b'/' => Ok(63),
b'=' => Ok(0),
_ => Err(Error::Generic {
store: "GcpSecretManager",
source: Box::new(StringError(format!("invalid base64 char: {c}"))),
}),
}
}
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
for chunk in bytes.chunks(4) {
if chunk.len() < 4 {
break;
}
let a = val(chunk[0])?;
let b = val(chunk[1])?;
let c = val(chunk[2])?;
let d = val(chunk[3])?;
out.push((a << 2) | (b >> 4));
if chunk[2] != b'=' {
out.push((b << 4) | (c >> 2));
}
if chunk[3] != b'=' {
out.push((c << 6) | d);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_roundtrip_ascii() {
let original = b"Hello, GCP Secret Manager!";
let encoded = base64_encode(original);
let decoded = base64_decode(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn base64_roundtrip_binary() {
let original: Vec<u8> = (0u8..=255).collect();
let encoded = base64_encode(&original);
let decoded = base64_decode(&encoded).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn base64_encode_empty() {
assert_eq!(base64_encode(b""), "");
}
#[test]
fn base64_decode_invalid_char_returns_error() {
assert!(base64_decode("!!!!").is_err());
}
}