1use async_trait::async_trait;
4use cuenv_secrets::{SecretError, SecretResolver, SecretSpec};
5use serde::{Deserialize, Serialize};
6use tokio::process::Command;
7use vaultrs::client::VaultClient;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "camelCase")]
12pub struct VaultSecretConfig {
13 pub path: String,
15
16 pub key: String,
18
19 #[serde(default = "default_mount")]
21 pub mount: String,
22}
23
24fn default_mount() -> String {
25 "secret".to_string()
26}
27
28impl VaultSecretConfig {
29 #[must_use]
31 pub fn new(path: impl Into<String>, key: impl Into<String>) -> Self {
32 Self {
33 path: path.into(),
34 key: key.into(),
35 mount: "secret".to_string(),
36 }
37 }
38
39 #[must_use]
41 pub fn full_path(&self) -> String {
42 if self.path.starts_with(&self.mount) {
44 self.path.clone()
45 } else {
46 format!("{}/data/{}", self.mount, self.path)
47 }
48 }
49}
50
51pub struct VaultResolver {
60 client: Option<VaultClient>,
61}
62
63impl std::fmt::Debug for VaultResolver {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("VaultResolver")
66 .field("mode", &if self.can_use_http() { "http" } else { "cli" })
67 .finish()
68 }
69}
70
71impl VaultResolver {
72 pub fn new() -> Result<Self, SecretError> {
81 let client = if Self::http_credentials_available() {
82 let addr =
83 std::env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string());
84 let token =
85 std::env::var("VAULT_TOKEN").map_err(|_| SecretError::ResolutionFailed {
86 name: "vault".to_string(),
87 message: "VAULT_TOKEN environment variable not set".to_string(),
88 })?;
89
90 Some(
91 VaultClient::new(
92 vaultrs::client::VaultClientSettingsBuilder::default()
93 .address(addr)
94 .token(token)
95 .build()
96 .map_err(|e| SecretError::ResolutionFailed {
97 name: "vault".to_string(),
98 message: format!("Failed to build Vault client: {e}"),
99 })?,
100 )
101 .map_err(|e| SecretError::ResolutionFailed {
102 name: "vault".to_string(),
103 message: format!("Failed to create Vault client: {e}"),
104 })?,
105 )
106 } else {
107 None
108 };
109
110 Ok(Self { client })
111 }
112
113 fn http_credentials_available() -> bool {
115 std::env::var("VAULT_TOKEN").is_ok() && std::env::var("VAULT_ADDR").is_ok()
116 }
117
118 const fn can_use_http(&self) -> bool {
120 self.client.is_some()
121 }
122
123 async fn resolve_http(
125 &self,
126 name: &str,
127 config: &VaultSecretConfig,
128 ) -> Result<String, SecretError> {
129 let client = self
130 .client
131 .as_ref()
132 .ok_or_else(|| SecretError::ResolutionFailed {
133 name: name.to_string(),
134 message: "Vault HTTP client not initialized".to_string(),
135 })?;
136
137 let secret: std::collections::HashMap<String, String> =
139 vaultrs::kv2::read(client, &config.mount, &config.path)
140 .await
141 .map_err(|e| SecretError::ResolutionFailed {
142 name: name.to_string(),
143 message: format!("Vault read error: {e}"),
144 })?;
145
146 secret
147 .get(&config.key)
148 .cloned()
149 .ok_or_else(|| SecretError::ResolutionFailed {
150 name: name.to_string(),
151 message: format!("Key '{}' not found in secret", config.key),
152 })
153 }
154
155 async fn resolve_cli(
157 &self,
158 name: &str,
159 config: &VaultSecretConfig,
160 ) -> Result<String, SecretError> {
161 let output = Command::new("vault")
162 .args([
163 "kv",
164 "get",
165 "-mount",
166 &config.mount,
167 "-field",
168 &config.key,
169 &config.path,
170 ])
171 .output()
172 .await
173 .map_err(|e| SecretError::ResolutionFailed {
174 name: name.to_string(),
175 message: format!("Failed to execute vault CLI: {e}"),
176 })?;
177
178 if !output.status.success() {
179 let stderr = String::from_utf8_lossy(&output.stderr);
180 return Err(SecretError::ResolutionFailed {
181 name: name.to_string(),
182 message: format!("vault CLI failed: {stderr}"),
183 });
184 }
185
186 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
187 }
188
189 async fn resolve_with_config(
191 &self,
192 name: &str,
193 config: &VaultSecretConfig,
194 ) -> Result<String, SecretError> {
195 if self.client.is_some() {
197 return self.resolve_http(name, config).await;
198 }
199
200 self.resolve_cli(name, config).await
202 }
203}
204
205#[async_trait]
206impl SecretResolver for VaultResolver {
207 fn provider_name(&self) -> &'static str {
208 "vault"
209 }
210
211 async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
212 let config: VaultSecretConfig =
214 serde_json::from_str(&spec.source).map_err(|e| SecretError::ResolutionFailed {
215 name: name.to_string(),
216 message: format!("Invalid Vault secret config: {e}"),
217 })?;
218
219 self.resolve_with_config(name, &config).await
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn test_vault_config_serialization() {
229 let config = VaultSecretConfig {
230 path: "myapp/config".to_string(),
231 key: "password".to_string(),
232 mount: "secret".to_string(),
233 };
234
235 let json = serde_json::to_string(&config).unwrap();
236 let parsed: VaultSecretConfig = serde_json::from_str(&json).unwrap();
237 assert_eq!(config, parsed);
238 }
239
240 #[test]
241 fn test_full_path() {
242 let config = VaultSecretConfig::new("myapp/config", "password");
243 assert_eq!(config.full_path(), "secret/data/myapp/config");
244 }
245
246 #[test]
247 fn test_full_path_with_mount() {
248 let config = VaultSecretConfig {
249 path: "myapp/config".to_string(),
250 key: "password".to_string(),
251 mount: "kv".to_string(),
252 };
253 assert_eq!(config.full_path(), "kv/data/myapp/config");
254 }
255
256 #[test]
257 fn test_http_credentials_check() {
258 let _ = VaultResolver::http_credentials_available();
260 }
261
262 #[test]
263 fn test_vault_config_new() {
264 let config = VaultSecretConfig::new("path/to/secret", "api_key");
265 assert_eq!(config.path, "path/to/secret");
266 assert_eq!(config.key, "api_key");
267 assert_eq!(config.mount, "secret"); }
269
270 #[test]
271 fn test_vault_config_new_with_string_types() {
272 let config = VaultSecretConfig::new(String::from("my/path"), String::from("mykey"));
273 assert_eq!(config.path, "my/path");
274 assert_eq!(config.key, "mykey");
275 }
276
277 #[test]
278 fn test_full_path_already_includes_mount() {
279 let config = VaultSecretConfig {
281 path: "secret/myapp/config".to_string(),
282 key: "password".to_string(),
283 mount: "secret".to_string(),
284 };
285 assert_eq!(config.full_path(), "secret/myapp/config");
286 }
287
288 #[test]
289 fn test_full_path_custom_mount_already_included() {
290 let config = VaultSecretConfig {
291 path: "kv/production/db".to_string(),
292 key: "password".to_string(),
293 mount: "kv".to_string(),
294 };
295 assert_eq!(config.full_path(), "kv/production/db");
296 }
297
298 #[test]
299 fn test_default_mount_function() {
300 assert_eq!(default_mount(), "secret");
301 }
302
303 #[test]
304 fn test_vault_config_clone() {
305 let config = VaultSecretConfig {
306 path: "myapp/config".to_string(),
307 key: "password".to_string(),
308 mount: "kv".to_string(),
309 };
310 let cloned = config.clone();
311 assert_eq!(config, cloned);
312 }
313
314 #[test]
315 fn test_vault_config_debug() {
316 let config = VaultSecretConfig::new("test/path", "test_key");
317 let debug_str = format!("{config:?}");
318 assert!(debug_str.contains("VaultSecretConfig"));
319 assert!(debug_str.contains("test/path"));
320 assert!(debug_str.contains("test_key"));
321 }
322
323 #[test]
324 fn test_vault_config_deserialization_with_defaults() {
325 let json = r#"{"path": "myapp", "key": "secret"}"#;
327 let config: VaultSecretConfig = serde_json::from_str(json).unwrap();
328 assert_eq!(config.path, "myapp");
329 assert_eq!(config.key, "secret");
330 assert_eq!(config.mount, "secret"); }
332
333 #[test]
334 fn test_vault_config_full_deserialization() {
335 let json = r#"{"path": "prod/db", "key": "password", "mount": "kv"}"#;
336 let config: VaultSecretConfig = serde_json::from_str(json).unwrap();
337 assert_eq!(config.path, "prod/db");
338 assert_eq!(config.key, "password");
339 assert_eq!(config.mount, "kv");
340 }
341
342 #[test]
343 fn test_vault_config_camel_case_serialization() {
344 let config = VaultSecretConfig::new("mypath", "mykey");
345 let json = serde_json::to_string(&config).unwrap();
346 assert!(json.contains("\"path\""));
348 assert!(json.contains("\"key\""));
349 assert!(json.contains("\"mount\""));
350 }
351
352 #[test]
353 fn test_full_path_deep_nesting() {
354 let config = VaultSecretConfig {
355 path: "environments/production/services/api/database".to_string(),
356 key: "connection_string".to_string(),
357 mount: "secret".to_string(),
358 };
359 assert_eq!(
360 config.full_path(),
361 "secret/data/environments/production/services/api/database"
362 );
363 }
364
365 #[test]
366 fn test_vault_config_equality() {
367 let config1 = VaultSecretConfig::new("path", "key");
368 let config2 = VaultSecretConfig::new("path", "key");
369 let config3 = VaultSecretConfig::new("path", "different_key");
370
371 assert_eq!(config1, config2);
372 assert_ne!(config1, config3);
373 }
374
375 #[test]
376 fn test_vault_config_with_different_mounts() {
377 let mut config1 = VaultSecretConfig::new("path", "key");
378 config1.mount = "kv".to_string();
379 let mut config2 = VaultSecretConfig::new("path", "key");
380 config2.mount = "secrets".to_string();
381
382 assert_ne!(config1, config2);
383 }
384
385 #[test]
386 fn test_vault_config_roundtrip() {
387 let original = VaultSecretConfig {
388 path: "deep/nested/path".to_string(),
389 key: "complex_key".to_string(),
390 mount: "custom_mount".to_string(),
391 };
392 let json = serde_json::to_string(&original).unwrap();
393 let parsed: VaultSecretConfig = serde_json::from_str(&json).unwrap();
394 assert_eq!(original, parsed);
395 }
396
397 #[test]
398 fn test_full_path_with_special_characters() {
399 let config = VaultSecretConfig {
400 path: "my-app/config_v1".to_string(),
401 key: "api-key".to_string(),
402 mount: "secret".to_string(),
403 };
404 assert_eq!(config.full_path(), "secret/data/my-app/config_v1");
405 }
406
407 #[test]
408 fn test_full_path_empty_path() {
409 let config = VaultSecretConfig {
410 path: String::new(),
411 key: "key".to_string(),
412 mount: "secret".to_string(),
413 };
414 assert_eq!(config.full_path(), "secret/data/");
415 }
416
417 #[test]
418 fn test_vault_config_deserialization_missing_path() {
419 let json = r#"{"key": "mykey"}"#;
420 let result = serde_json::from_str::<VaultSecretConfig>(json);
421 assert!(result.is_err());
422 }
423
424 #[test]
425 fn test_vault_config_deserialization_missing_key() {
426 let json = r#"{"path": "mypath"}"#;
427 let result = serde_json::from_str::<VaultSecretConfig>(json);
428 assert!(result.is_err());
429 }
430
431 #[test]
432 fn test_resolver_new_without_credentials() {
433 if std::env::var("VAULT_TOKEN").is_err() || std::env::var("VAULT_ADDR").is_err() {
435 let resolver = VaultResolver::new();
436 assert!(resolver.is_ok());
437 let resolver = resolver.unwrap();
438 assert!(!resolver.can_use_http());
439 }
440 }
441
442 #[test]
443 fn test_resolver_provider_name() {
444 if std::env::var("VAULT_TOKEN").is_err() || std::env::var("VAULT_ADDR").is_err() {
445 let resolver = VaultResolver::new().unwrap();
446 assert_eq!(resolver.provider_name(), "vault");
447 }
448 }
449
450 #[test]
451 fn test_resolver_debug_output() {
452 if std::env::var("VAULT_TOKEN").is_err() || std::env::var("VAULT_ADDR").is_err() {
453 let resolver = VaultResolver::new().unwrap();
454 let debug = format!("{resolver:?}");
455 assert!(debug.contains("VaultResolver"));
456 assert!(debug.contains("cli") || debug.contains("http"));
457 }
458 }
459
460 #[test]
461 fn test_http_credentials_available_logic() {
462 let token = std::env::var("VAULT_TOKEN").is_ok();
463 let addr = std::env::var("VAULT_ADDR").is_ok();
464 let expected = token && addr;
465 assert_eq!(VaultResolver::http_credentials_available(), expected);
466 }
467
468 #[test]
469 fn test_vault_config_unicode_path() {
470 let config = VaultSecretConfig::new("路径/配置", "密钥");
471 assert_eq!(config.path, "路径/配置");
472 assert_eq!(config.key, "密钥");
473 assert_eq!(config.full_path(), "secret/data/路径/配置");
474 }
475
476 #[test]
477 fn test_vault_config_with_slashes_in_key() {
478 let config = VaultSecretConfig {
479 path: "myapp/config".to_string(),
480 key: "nested/key/path".to_string(),
481 mount: "secret".to_string(),
482 };
483 assert_eq!(config.key, "nested/key/path");
484 }
485
486 #[test]
487 fn test_vault_config_empty_key() {
488 let config = VaultSecretConfig {
489 path: "myapp/config".to_string(),
490 key: String::new(),
491 mount: "secret".to_string(),
492 };
493 assert_eq!(config.key, "");
494 }
495
496 #[test]
497 fn test_full_path_mount_partial_match() {
498 let config = VaultSecretConfig {
500 path: "secretive/path".to_string(),
501 key: "key".to_string(),
502 mount: "secret".to_string(),
503 };
504 assert_eq!(config.full_path(), "secretive/path");
506 }
507
508 #[test]
509 fn test_full_path_different_mount_prefix() {
510 let config = VaultSecretConfig {
512 path: "other/path".to_string(),
513 key: "key".to_string(),
514 mount: "kv".to_string(),
515 };
516 assert_eq!(config.full_path(), "kv/data/other/path");
517 }
518}