1use async_trait::async_trait;
4use cuenv_secrets::{SecretError, SecretResolver, SecretSpec};
5use serde::{Deserialize, Serialize};
6use tokio::process::Command;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "camelCase")]
11pub struct GcpSecretConfig {
12 pub project: String,
14
15 pub secret: String,
17
18 #[serde(default = "default_version")]
20 pub version: String,
21}
22
23fn default_version() -> String {
24 "latest".to_string()
25}
26
27impl GcpSecretConfig {
28 #[must_use]
30 pub fn new(project: impl Into<String>, secret: impl Into<String>) -> Self {
31 Self {
32 project: project.into(),
33 secret: secret.into(),
34 version: "latest".to_string(),
35 }
36 }
37
38 #[must_use]
40 pub fn resource_name(&self) -> String {
41 format!(
42 "projects/{}/secrets/{}/versions/{}",
43 self.project, self.secret, self.version
44 )
45 }
46}
47
48pub struct GcpResolver {
58 use_http: bool,
59}
60
61impl std::fmt::Debug for GcpResolver {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_struct("GcpResolver")
64 .field("mode", &if self.can_use_http() { "http" } else { "cli" })
65 .finish()
66 }
67}
68
69impl GcpResolver {
70 pub fn new() -> Result<Self, SecretError> {
78 let use_http = Self::http_credentials_available();
79 Ok(Self { use_http })
80 }
81
82 fn http_credentials_available() -> bool {
84 std::env::var("GOOGLE_APPLICATION_CREDENTIALS").is_ok()
85 }
86
87 const fn can_use_http(&self) -> bool {
89 self.use_http
90 }
91
92 async fn resolve_http(
94 &self,
95 name: &str,
96 config: &GcpSecretConfig,
97 ) -> Result<String, SecretError> {
98 tracing::warn!(
101 "GCP HTTP mode not yet fully implemented, falling back to CLI for secret '{}'",
102 name
103 );
104 self.resolve_cli(name, config).await
105 }
106
107 async fn resolve_cli(
109 &self,
110 name: &str,
111 config: &GcpSecretConfig,
112 ) -> Result<String, SecretError> {
113 let output = Command::new("gcloud")
114 .args([
115 "secrets",
116 "versions",
117 "access",
118 &config.version,
119 "--secret",
120 &config.secret,
121 "--project",
122 &config.project,
123 ])
124 .output()
125 .await
126 .map_err(|e| SecretError::ResolutionFailed {
127 name: name.to_string(),
128 message: format!("Failed to execute gcloud CLI: {e}"),
129 })?;
130
131 if !output.status.success() {
132 let stderr = String::from_utf8_lossy(&output.stderr);
133 return Err(SecretError::ResolutionFailed {
134 name: name.to_string(),
135 message: format!("gcloud CLI failed: {stderr}"),
136 });
137 }
138
139 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
140 }
141
142 fn parse_resource_name(resource_name: &str) -> Option<GcpSecretConfig> {
144 let parts: Vec<&str> = resource_name.split('/').collect();
146 if parts.len() >= 6
147 && parts[0] == "projects"
148 && parts[2] == "secrets"
149 && parts[4] == "versions"
150 {
151 Some(GcpSecretConfig {
152 project: parts[1].to_string(),
153 secret: parts[3].to_string(),
154 version: parts[5].to_string(),
155 })
156 } else {
157 None
158 }
159 }
160
161 async fn resolve_with_config(
163 &self,
164 name: &str,
165 config: &GcpSecretConfig,
166 ) -> Result<String, SecretError> {
167 if self.use_http {
169 return self.resolve_http(name, config).await;
170 }
171
172 self.resolve_cli(name, config).await
174 }
175}
176
177#[async_trait]
178impl SecretResolver for GcpResolver {
179 fn provider_name(&self) -> &'static str {
180 "gcp"
181 }
182
183 async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
184 if let Ok(config) = serde_json::from_str::<GcpSecretConfig>(&spec.source) {
186 return self.resolve_with_config(name, &config).await;
187 }
188
189 if let Some(config) = Self::parse_resource_name(&spec.source) {
191 return self.resolve_with_config(name, &config).await;
192 }
193
194 Err(SecretError::ResolutionFailed {
195 name: name.to_string(),
196 message: format!(
197 "Invalid GCP secret source. Expected JSON config or resource name, got: {}",
198 spec.source
199 ),
200 })
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn test_gcp_config_serialization() {
210 let config = GcpSecretConfig {
211 project: "my-project".to_string(),
212 secret: "my-secret".to_string(),
213 version: "latest".to_string(),
214 };
215
216 let json = serde_json::to_string(&config).unwrap();
217 let parsed: GcpSecretConfig = serde_json::from_str(&json).unwrap();
218 assert_eq!(config, parsed);
219 }
220
221 #[test]
222 fn test_resource_name() {
223 let config = GcpSecretConfig::new("my-project", "my-secret");
224 assert_eq!(
225 config.resource_name(),
226 "projects/my-project/secrets/my-secret/versions/latest"
227 );
228 }
229
230 #[test]
231 fn test_parse_resource_name() {
232 let config =
233 GcpResolver::parse_resource_name("projects/my-project/secrets/my-secret/versions/5")
234 .unwrap();
235 assert_eq!(config.project, "my-project");
236 assert_eq!(config.secret, "my-secret");
237 assert_eq!(config.version, "5");
238 }
239
240 #[test]
241 fn test_parse_invalid_resource_name() {
242 assert!(GcpResolver::parse_resource_name("invalid/path").is_none());
243 }
244
245 #[test]
246 fn test_http_credentials_check() {
247 let _ = GcpResolver::http_credentials_available();
249 }
250
251 #[test]
252 fn test_gcp_config_new() {
253 let config = GcpSecretConfig::new("my-project", "api-key");
254 assert_eq!(config.project, "my-project");
255 assert_eq!(config.secret, "api-key");
256 assert_eq!(config.version, "latest");
257 }
258
259 #[test]
260 fn test_gcp_config_new_with_string_types() {
261 let config = GcpSecretConfig::new(String::from("project-id"), String::from("secret-name"));
262 assert_eq!(config.project, "project-id");
263 assert_eq!(config.secret, "secret-name");
264 }
265
266 #[test]
267 fn test_default_version_function() {
268 assert_eq!(default_version(), "latest");
269 }
270
271 #[test]
272 fn test_resource_name_with_specific_version() {
273 let config = GcpSecretConfig {
274 project: "prod-project".to_string(),
275 secret: "database-password".to_string(),
276 version: "42".to_string(),
277 };
278 assert_eq!(
279 config.resource_name(),
280 "projects/prod-project/secrets/database-password/versions/42"
281 );
282 }
283
284 #[test]
285 fn test_gcp_config_clone() {
286 let config = GcpSecretConfig {
287 project: "my-project".to_string(),
288 secret: "my-secret".to_string(),
289 version: "latest".to_string(),
290 };
291 let cloned = config.clone();
292 assert_eq!(config, cloned);
293 }
294
295 #[test]
296 fn test_gcp_config_debug() {
297 let config = GcpSecretConfig::new("test-project", "test-secret");
298 let debug_str = format!("{config:?}");
299 assert!(debug_str.contains("GcpSecretConfig"));
300 assert!(debug_str.contains("test-project"));
301 assert!(debug_str.contains("test-secret"));
302 }
303
304 #[test]
305 fn test_parse_resource_name_with_latest() {
306 let config = GcpResolver::parse_resource_name(
307 "projects/my-project/secrets/my-secret/versions/latest",
308 )
309 .unwrap();
310 assert_eq!(config.project, "my-project");
311 assert_eq!(config.secret, "my-secret");
312 assert_eq!(config.version, "latest");
313 }
314
315 #[test]
316 fn test_parse_resource_name_missing_projects() {
317 assert!(
318 GcpResolver::parse_resource_name("my-project/secrets/my-secret/versions/1").is_none()
319 );
320 }
321
322 #[test]
323 fn test_parse_resource_name_missing_secrets() {
324 assert!(
325 GcpResolver::parse_resource_name("projects/my-project/my-secret/versions/1").is_none()
326 );
327 }
328
329 #[test]
330 fn test_parse_resource_name_missing_versions() {
331 assert!(
332 GcpResolver::parse_resource_name("projects/my-project/secrets/my-secret/1").is_none()
333 );
334 }
335
336 #[test]
337 fn test_parse_resource_name_too_short() {
338 assert!(GcpResolver::parse_resource_name("projects/my-project/secrets").is_none());
339 }
340
341 #[test]
342 fn test_gcp_config_deserialization_with_defaults() {
343 let json = r#"{"project": "my-project", "secret": "my-secret"}"#;
345 let config: GcpSecretConfig = serde_json::from_str(json).unwrap();
346 assert_eq!(config.project, "my-project");
347 assert_eq!(config.secret, "my-secret");
348 assert_eq!(config.version, "latest");
349 }
350
351 #[test]
352 fn test_gcp_config_full_serialization() {
353 let config = GcpSecretConfig {
354 project: "prod".to_string(),
355 secret: "api-key".to_string(),
356 version: "5".to_string(),
357 };
358 let json = serde_json::to_string(&config).unwrap();
359 assert!(json.contains("\"project\":\"prod\""));
361 assert!(json.contains("\"secret\":\"api-key\""));
362 assert!(json.contains("\"version\":\"5\""));
363 }
364
365 #[test]
366 fn test_gcp_config_equality() {
367 let config1 = GcpSecretConfig::new("project", "secret");
368 let config2 = GcpSecretConfig::new("project", "secret");
369 let config3 = GcpSecretConfig::new("project", "other-secret");
370
371 assert_eq!(config1, config2);
372 assert_ne!(config1, config3);
373 }
374
375 #[test]
376 fn test_gcp_resolver_new() {
377 let resolver = GcpResolver::new();
378 assert!(resolver.is_ok());
379 }
380
381 #[test]
382 fn test_gcp_resolver_debug() {
383 let resolver = GcpResolver::new().unwrap();
384 let debug_str = format!("{resolver:?}");
385 assert!(debug_str.contains("GcpResolver"));
386 assert!(debug_str.contains("mode"));
388 }
389
390 #[test]
391 fn test_gcp_resolver_provider_name() {
392 let resolver = GcpResolver::new().unwrap();
393 assert_eq!(resolver.provider_name(), "gcp");
394 }
395}