secret_store 0.1.1

A unified, async secret store interface for Azure Key Vault, AWS Secrets Manager, GCP Secret Manager, and generic HTTP endpoints
Documentation
//! GCP Secret Manager HTTP abstraction and real adapter.

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";

// ─────────────────────────────────────────────────────────────────────────────
// SDK abstraction trait
// ─────────────────────────────────────────────────────────────────────────────

/// Low-level GCP Secret Manager operations.
///
/// The real implementation talks to the Secret Manager REST API; unit tests
/// inject a `MockGcpSmOps` generated by `mockall`.
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait GcpSmOps: Send + Sync {
    /// Minimal label used by `Display` (e.g. `"GcpSecretManager(project=my-proj)"`).
    fn display_name(&self) -> String;
    /// Verbose info used by `Debug` (project ID, API endpoint, provider tag).
    fn debug_info(&self) -> String;

    async fn get(&self, name: &str) -> Result<String>;
    /// Creates the secret resource and adds the first version.
    async fn create(&self, name: &str, value: &str) -> Result<()>;
    /// Adds a new version to an existing secret.
    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>>;
}

// ─────────────────────────────────────────────────────────────────────────────
// REST response types
// ─────────────────────────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct AccessSecretResponse {
    payload: SecretPayload,
}

#[derive(Deserialize)]
struct SecretPayload {
    data: String, // base64-encoded
}

#[derive(Deserialize)]
struct ListSecretsResponse {
    secrets: Option<Vec<SecretEntry>>,
    #[serde(rename = "nextPageToken")]
    next_page_token: Option<String>,
}

#[derive(Deserialize)]
struct SecretEntry {
    name: String, // projects/{proj}/secrets/{name}
}

// ─────────────────────────────────────────────────────────────────────────────
// Real HTTP adapter
// ─────────────────────────────────────────────────────────────────────────────

/// Calls the GCP Secret Manager REST API using `gcp-auth` + `reqwest`.
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)
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Base64 helpers (avoids pulling in base64 crate for a small use case)
// ─────────────────────────────────────────────────────────────────────────────

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)
}

// ─────────────────────────────────────────────────────────────────────────────
// Tests for base64 helpers
// ─────────────────────────────────────────────────────────────────────────────

#[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());
    }
}