use super::{Address, Provider, ProviderUrl};
use crate::{Result, SecretSpecError};
use google_cloud_secretmanager_v1::client::SecretManagerService;
use google_cloud_secretmanager_v1::model::{Replication, Secret, SecretPayload, replication};
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GcsmConfig {
pub project_id: String,
}
fn validate_gcp_project_id(project_id: &str) -> std::result::Result<(), SecretSpecError> {
let len = project_id.len();
if len < 6 || len > 30 {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"GCP project ID must be 6-30 characters, got {}",
len
)));
}
let mut chars = project_id.chars().peekable();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => {
return Err(SecretSpecError::ProviderOperationFailed(
"GCP project ID must start with a lowercase letter".to_string(),
));
}
}
for c in chars {
if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '-' {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"GCP project ID contains invalid character '{}'. \
Only lowercase letters, digits, and hyphens are allowed",
c
)));
}
}
if project_id.ends_with('-') {
return Err(SecretSpecError::ProviderOperationFailed(
"GCP project ID cannot end with a hyphen".to_string(),
));
}
Ok(())
}
impl TryFrom<&ProviderUrl> for GcsmConfig {
type Error = SecretSpecError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
if url.scheme() != "gcsm" {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Invalid scheme '{}' for gcsm provider. Expected 'gcsm'.",
url.scheme()
)));
}
let project_id = url.host().filter(|s| !s.is_empty()).ok_or_else(|| {
SecretSpecError::ProviderOperationFailed(
"GCP project ID is required. Use format: gcsm://project-id".to_string(),
)
})?;
validate_gcp_project_id(&project_id)?;
let path = url.path();
let trimmed = path.trim_start_matches('/');
if !trimmed.is_empty() {
let id = trimmed
.strip_prefix("secrets/")
.unwrap_or(trimmed)
.split('/')
.next()
.unwrap_or(trimmed);
let hint = crate::config::ref_table_hint(None, id, None, None);
return Err(SecretSpecError::ProviderOperationFailed(format!(
"gcsm URIs take no path: address the secret with \
{hint} on the secret instead \
(add version = \"<n>\" to pin a version)"
)));
}
Ok(Self { project_id })
}
}
pub struct GcsmProvider {
config: GcsmConfig,
}
crate::register_provider! {
struct: GcsmProvider,
config: GcsmConfig,
name: "gcsm",
description: "Google Cloud Secret Manager",
schemes: ["gcsm"],
examples: ["gcsm://my-gcp-project"],
}
impl GcsmProvider {
pub fn new(config: GcsmConfig) -> Self {
Self { config }
}
fn validate_name_component(name: &str, component: &str) -> Result<()> {
if component.is_empty() {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"{} cannot be empty",
name
)));
}
for c in component.chars() {
if !c.is_ascii_alphanumeric() && c != '_' && c != '-' {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"{} contains invalid character '{}'. \
Only alphanumeric characters, underscores, and hyphens are allowed",
name, c
)));
}
}
Ok(())
}
fn format_secret_name(&self, project: &str, profile: &str, key: &str) -> Result<String> {
Self::validate_name_component("project", project)?;
Self::validate_name_component("profile", profile)?;
Self::validate_name_component("key", key)?;
let secret_name = format!("secretspec-{}-{}-{}", project, profile, key);
if secret_name.len() > 255 {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Secret name too long: {} characters (max 255)",
secret_name.len()
)));
}
Ok(secret_name)
}
fn is_not_found_error(e: &impl std::error::Error) -> bool {
let s = e.to_string();
s.contains("NOT_FOUND") || s.contains("notFound")
}
fn is_already_exists_error(e: &impl std::error::Error) -> bool {
let s = e.to_string();
s.contains("ALREADY_EXISTS") || s.contains("alreadyExists")
}
async fn create_client(&self) -> Result<SecretManagerService> {
SecretManagerService::builder().build().await.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to create GCP Secret Manager client: {}\n\n\
Ensure Application Default Credentials are configured:\n \
- Local development: Run 'gcloud auth application-default login'\n \
- Service account: Set GOOGLE_APPLICATION_CREDENTIALS environment variable\n \
- GKE: Configure Workload Identity",
e
))
})
}
async fn get_coords_async(
&self,
coords: &crate::config::NativeAddress,
) -> Result<Option<SecretString>> {
let version = coords.version.as_deref().unwrap_or("latest");
let secret_version_path = format!(
"projects/{}/secrets/{}/versions/{}",
self.config.project_id, coords.item, version
);
let client = self.create_client().await?;
match client
.access_secret_version()
.set_name(&secret_version_path)
.send()
.await
{
Ok(response) => {
if let Some(payload) = response.payload {
let data = String::from_utf8(payload.data.to_vec()).map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Secret data is not valid UTF-8: {}",
e
))
})?;
Ok(Some(SecretString::new(data.into())))
} else {
Ok(None)
}
}
Err(e) => {
if Self::is_not_found_error(&e) {
Ok(None)
} else {
Err(SecretSpecError::ProviderOperationFailed(format!(
"Failed to access secret '{}': {}",
coords.item, e
)))
}
}
}
}
async fn set_secret_async(&self, secret_name: &str, value: &SecretString) -> Result<()> {
let client = self.create_client().await?;
let create_result = client
.create_secret()
.set_parent(format!("projects/{}", self.config.project_id))
.set_secret_id(secret_name)
.set_secret(Secret::default().set_replication(
Replication::default().set_automatic(replication::Automatic::default()),
))
.send()
.await;
if let Err(e) = create_result {
if !Self::is_already_exists_error(&e) {
return Err(SecretSpecError::ProviderOperationFailed(format!(
"Failed to create secret '{}': {}",
secret_name, e
)));
}
}
client
.add_secret_version()
.set_parent(format!(
"projects/{}/secrets/{}",
self.config.project_id, secret_name
))
.set_payload(
SecretPayload::default().set_data(value.expose_secret().as_bytes().to_vec()),
)
.send()
.await
.map_err(|e| {
SecretSpecError::ProviderOperationFailed(format!(
"Failed to add secret version for '{}': {}",
secret_name, e
))
})?;
Ok(())
}
}
impl Provider for GcsmProvider {
fn convention_address(
&self,
project: &str,
profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: self.format_secret_name(project, profile, key)?,
..Default::default()
})
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
format!("gcsm://{}", self.config.project_id)
}
fn supported_coords(&self) -> &'static [&'static str] {
&["version"]
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let coords = self.resolve_coords(addr)?;
super::block_on(self.get_coords_async(&coords))
}
fn set(&self, addr: Address<'_>, value: &SecretString) -> Result<()> {
self.check_writable(addr)?;
let coords = self.resolve_coords(addr)?;
super::block_on(self.set_secret_async(&coords.item, value))
}
fn check_writable(&self, addr: Address<'_>) -> Result<()> {
match addr {
Address::Convention { .. } => Ok(()),
Address::Native(_) => Err(SecretSpecError::ProviderOperationFailed(
"gcsm secret references are read-only and cannot be written".to_string(),
)),
}
}
}
#[cfg(test)]
mod reference_tests {
use super::*;
use url::Url;
#[test]
fn path_is_rejected_with_ref_hint() {
let err = GcsmConfig::try_from(&ProviderUrl::new(
Url::parse("gcsm://my-project/secrets/db-url/versions/3").unwrap(),
))
.unwrap_err();
assert!(
err.to_string().contains("ref = { item = \"db-url\" }"),
"{err}"
);
}
#[test]
fn native_address_is_read_only() {
let c = GcsmConfig::try_from(&ProviderUrl::new(Url::parse("gcsm://my-project").unwrap()))
.unwrap();
let p = GcsmProvider::new(c);
let addr = crate::config::NativeAddress {
item: "db-url".into(),
..Default::default()
};
let refusal = p.check_writable(Address::Native(&addr)).unwrap_err();
assert!(refusal.to_string().contains("read-only"), "{refusal}");
let err = p
.set(
Address::Native(&addr),
&secrecy::SecretString::new("v".into()),
)
.unwrap_err();
assert_eq!(err.to_string(), refusal.to_string());
}
#[test]
fn native_address_rejects_field() {
let c = GcsmConfig::try_from(&ProviderUrl::new(Url::parse("gcsm://my-project").unwrap()))
.unwrap();
let p = GcsmProvider::new(c);
let addr = crate::config::NativeAddress {
item: "db-url".into(),
field: Some("x".into()),
..Default::default()
};
let err = p.get(Address::Native(&addr)).unwrap_err();
assert!(err.to_string().contains("`field`"), "{err}");
}
}