cuenv_core/secrets/
mod.rs

1//! Secret and resolver types
2//!
3//! Based on schema/secrets.cue
4//!
5//! This module provides:
6//! - `Secret`: CUE-compatible secret definition with resolver-based resolution
7//! - `SecretRegistry`: Dynamic resolver registration and lookup
8//! - `create_default_registry()`: Creates a registry with built-in resolvers
9//! - Re-exports from `cuenv_secrets`: Trait-based secret resolution system
10
11use crate::{Error, Result};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::sync::Arc;
16
17// Re-export core secret resolution types from cuenv-secrets
18pub use cuenv_secrets::{
19    BatchResolver, ResolvedSecrets, SaltConfig, SecretError, SecretRegistry, SecretResolver,
20    SecretSpec, compute_secret_fingerprint,
21};
22
23// Re-export resolver implementations
24pub use cuenv_secrets::resolvers::{EnvSecretResolver, ExecSecretResolver};
25
26// Conditionally re-export 1Password resolver when feature is enabled
27#[cfg(feature = "1password")]
28pub use cuenv_1password::secrets::{OnePasswordConfig, OnePasswordResolver};
29
30/// Create a default secret registry with all built-in resolvers
31///
32/// This registers:
33/// - `env`: Environment variable resolver
34/// - `exec`: Command execution resolver
35/// - `onepassword`: 1Password resolver (when `1password` feature is enabled)
36///
37/// # Errors
38///
39/// Returns an error if 1Password resolver initialization fails (when enabled).
40#[allow(clippy::unnecessary_wraps)]
41pub fn create_default_registry() -> Result<SecretRegistry> {
42    let mut registry = SecretRegistry::new();
43
44    // Register built-in resolvers
45    registry.register(Arc::new(EnvSecretResolver::new()));
46    registry.register(Arc::new(ExecSecretResolver::new()));
47
48    // Register 1Password resolver if feature is enabled
49    #[cfg(feature = "1password")]
50    {
51        let op_resolver = OnePasswordResolver::new().map_err(|e| {
52            Error::configuration(format!("Failed to initialize 1Password resolver: {e}"))
53        })?;
54        registry.register(Arc::new(op_resolver));
55    }
56
57    Ok(registry)
58}
59
60/// Resolver for executing commands to retrieve secret values
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct ExecResolver {
63    /// Command to execute
64    pub command: String,
65
66    /// Arguments to pass to the command
67    pub args: Vec<String>,
68}
69
70/// Secret definition with resolver
71///
72/// This is the CUE-compatible secret type used for Dagger secrets and environment
73/// variable resolution. Supports multiple resolver types:
74/// - `exec`: Execute a command to get the secret
75/// - `onepassword`: Resolve from 1Password using `ref` field
76/// - `aws`, `gcp`, `vault`: Cloud provider secrets
77///
78/// Resolution is delegated to the trait-based [`SecretResolver`] system.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct Secret {
81    /// Resolver type: "exec", "onepassword", "aws", "gcp", "vault"
82    pub resolver: String,
83
84    /// Command to execute (for exec resolver)
85    #[serde(default, skip_serializing_if = "String::is_empty")]
86    pub command: String,
87
88    /// Arguments to pass to the command (for exec resolver)
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub args: Vec<String>,
91
92    /// 1Password reference (for onepassword resolver), e.g., "op://vault/item/field"
93    #[serde(rename = "ref", default, skip_serializing_if = "Option::is_none")]
94    pub op_ref: Option<String>,
95
96    /// Additional fields for extensibility
97    #[serde(flatten)]
98    pub extra: HashMap<String, Value>,
99}
100
101impl Secret {
102    /// Create a new exec secret
103    #[must_use]
104    pub fn new(command: String, args: Vec<String>) -> Self {
105        Secret {
106            resolver: "exec".to_string(),
107            command,
108            args,
109            op_ref: None,
110            extra: HashMap::new(),
111        }
112    }
113
114    /// Create a 1Password secret
115    #[must_use]
116    pub fn onepassword(reference: impl Into<String>) -> Self {
117        Secret {
118            resolver: "onepassword".to_string(),
119            command: String::new(),
120            args: Vec::new(),
121            op_ref: Some(reference.into()),
122            extra: HashMap::new(),
123        }
124    }
125
126    /// Create a secret with additional fields
127    #[must_use]
128    pub fn with_extra(command: String, args: Vec<String>, extra: HashMap<String, Value>) -> Self {
129        Secret {
130            resolver: "exec".to_string(),
131            command,
132            args,
133            op_ref: None,
134            extra,
135        }
136    }
137
138    /// Get the resolver/provider name
139    #[must_use]
140    pub fn provider(&self) -> &str {
141        &self.resolver
142    }
143
144    /// Convert to a SecretSpec for use with the trait-based resolver system
145    #[must_use]
146    pub fn to_spec(&self) -> SecretSpec {
147        let source = match self.resolver.as_str() {
148            "onepassword" => self.op_ref.clone().unwrap_or_default(),
149            "exec" => serde_json::json!({
150                "command": self.command,
151                "args": self.args
152            })
153            .to_string(),
154            // For other resolvers, serialize the whole secret
155            _ => serde_json::to_string(self).unwrap_or_default(),
156        };
157        SecretSpec::new(source)
158    }
159
160    /// Resolve the secret value using the trait-based resolver system
161    ///
162    /// Uses the default registry with all built-in resolvers.
163    ///
164    /// # Errors
165    /// Returns error if resolution fails
166    pub async fn resolve(&self) -> Result<String> {
167        let registry = create_default_registry()?;
168        self.resolve_with_registry(&registry).await
169    }
170
171    /// Resolve the secret value using a custom registry
172    ///
173    /// # Errors
174    /// Returns error if resolution fails
175    pub async fn resolve_with_registry(&self, registry: &SecretRegistry) -> Result<String> {
176        let spec = self.to_spec();
177
178        registry
179            .resolve(&self.resolver, "secret", &spec)
180            .await
181            .map_err(|e| Error::configuration(format!("{e}")))
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use serde_json::json;
189
190    // ==========================================================================
191    // ExecResolver tests
192    // ==========================================================================
193
194    #[test]
195    fn test_exec_resolver_new() {
196        let resolver = ExecResolver {
197            command: "echo".to_string(),
198            args: vec!["hello".to_string()],
199        };
200
201        assert_eq!(resolver.command, "echo");
202        assert_eq!(resolver.args, vec!["hello"]);
203    }
204
205    #[test]
206    fn test_exec_resolver_serde_roundtrip() {
207        let resolver = ExecResolver {
208            command: "/usr/bin/vault".to_string(),
209            args: vec![
210                "read".to_string(),
211                "-field=value".to_string(),
212                "secret/data".to_string(),
213            ],
214        };
215
216        let json = serde_json::to_string(&resolver).unwrap();
217        let parsed: ExecResolver = serde_json::from_str(&json).unwrap();
218
219        assert_eq!(parsed.command, resolver.command);
220        assert_eq!(parsed.args, resolver.args);
221    }
222
223    #[test]
224    fn test_exec_resolver_clone() {
225        let resolver = ExecResolver {
226            command: "cmd".to_string(),
227            args: vec!["arg1".to_string()],
228        };
229
230        let cloned = resolver.clone();
231        assert_eq!(cloned.command, resolver.command);
232        assert_eq!(cloned.args, resolver.args);
233    }
234
235    #[test]
236    fn test_exec_resolver_eq() {
237        let r1 = ExecResolver {
238            command: "cmd".to_string(),
239            args: vec!["arg".to_string()],
240        };
241        let r2 = ExecResolver {
242            command: "cmd".to_string(),
243            args: vec!["arg".to_string()],
244        };
245        let r3 = ExecResolver {
246            command: "other".to_string(),
247            args: vec![],
248        };
249
250        assert_eq!(r1, r2);
251        assert_ne!(r1, r3);
252    }
253
254    // ==========================================================================
255    // Secret construction tests
256    // ==========================================================================
257
258    #[test]
259    fn test_secret_new_exec() {
260        let secret = Secret::new("echo".to_string(), vec!["hello".to_string()]);
261
262        assert_eq!(secret.resolver, "exec");
263        assert_eq!(secret.command, "echo");
264        assert_eq!(secret.args, vec!["hello"]);
265        assert!(secret.op_ref.is_none());
266        assert!(secret.extra.is_empty());
267    }
268
269    #[test]
270    fn test_secret_onepassword() {
271        let secret = Secret::onepassword("op://vault/item/field");
272
273        assert_eq!(secret.resolver, "onepassword");
274        assert_eq!(secret.op_ref, Some("op://vault/item/field".to_string()));
275        assert!(secret.command.is_empty());
276        assert!(secret.args.is_empty());
277    }
278
279    #[test]
280    fn test_secret_onepassword_with_into() {
281        let secret = Secret::onepassword(String::from("op://my-vault/my-item/password"));
282
283        assert_eq!(secret.resolver, "onepassword");
284        assert_eq!(
285            secret.op_ref,
286            Some("op://my-vault/my-item/password".to_string())
287        );
288    }
289
290    #[test]
291    fn test_secret_with_extra() {
292        let mut extra = HashMap::new();
293        extra.insert("region".to_string(), json!("us-east-1"));
294        extra.insert("version".to_string(), json!(2));
295
296        let secret = Secret::with_extra(
297            "aws".to_string(),
298            vec!["secretsmanager".to_string(), "get-secret-value".to_string()],
299            extra.clone(),
300        );
301
302        assert_eq!(secret.resolver, "exec");
303        assert_eq!(secret.command, "aws");
304        assert_eq!(secret.extra, extra);
305    }
306
307    // ==========================================================================
308    // Secret::provider tests
309    // ==========================================================================
310
311    #[test]
312    fn test_secret_provider_exec() {
313        let secret = Secret::new("cmd".to_string(), vec![]);
314        assert_eq!(secret.provider(), "exec");
315    }
316
317    #[test]
318    fn test_secret_provider_onepassword() {
319        let secret = Secret::onepassword("op://vault/item/field");
320        assert_eq!(secret.provider(), "onepassword");
321    }
322
323    #[test]
324    fn test_secret_provider_custom() {
325        let secret = Secret {
326            resolver: "vault".to_string(),
327            command: String::new(),
328            args: Vec::new(),
329            op_ref: None,
330            extra: HashMap::new(),
331        };
332        assert_eq!(secret.provider(), "vault");
333    }
334
335    // ==========================================================================
336    // Secret::to_spec tests
337    // ==========================================================================
338
339    #[test]
340    fn test_secret_to_spec_onepassword() {
341        let secret = Secret::onepassword("op://vault/item/field");
342        let spec = secret.to_spec();
343
344        assert_eq!(spec.source, "op://vault/item/field");
345    }
346
347    #[test]
348    fn test_secret_to_spec_exec() {
349        let secret = Secret::new("echo".to_string(), vec!["hello".to_string()]);
350        let spec = secret.to_spec();
351
352        let source = &spec.source;
353        assert!(source.contains("echo"));
354        assert!(source.contains("hello"));
355    }
356
357    #[test]
358    fn test_secret_to_spec_onepassword_empty_ref() {
359        let secret = Secret {
360            resolver: "onepassword".to_string(),
361            command: String::new(),
362            args: Vec::new(),
363            op_ref: None, // Missing ref
364            extra: HashMap::new(),
365        };
366        let spec = secret.to_spec();
367
368        // Should return empty string for missing ref
369        assert_eq!(spec.source, "");
370    }
371
372    #[test]
373    fn test_secret_to_spec_other_resolver() {
374        let mut extra = HashMap::new();
375        extra.insert("path".to_string(), json!("secret/data/myapp"));
376
377        let secret = Secret {
378            resolver: "vault".to_string(),
379            command: String::new(),
380            args: Vec::new(),
381            op_ref: None,
382            extra,
383        };
384        let spec = secret.to_spec();
385
386        // For other resolvers, the whole secret is serialized
387        let source = &spec.source;
388        assert!(source.contains("vault"));
389        assert!(source.contains("path"));
390    }
391
392    // ==========================================================================
393    // Secret serde tests
394    // ==========================================================================
395
396    #[test]
397    fn test_secret_serde_exec_roundtrip() {
398        let secret = Secret::new("echo".to_string(), vec!["test".to_string()]);
399        let json = serde_json::to_string(&secret).unwrap();
400        let parsed: Secret = serde_json::from_str(&json).unwrap();
401
402        assert_eq!(parsed.resolver, secret.resolver);
403        assert_eq!(parsed.command, secret.command);
404        assert_eq!(parsed.args, secret.args);
405    }
406
407    #[test]
408    fn test_secret_serde_onepassword_roundtrip() {
409        let secret = Secret::onepassword("op://vault/item/field");
410        let json = serde_json::to_string(&secret).unwrap();
411        let parsed: Secret = serde_json::from_str(&json).unwrap();
412
413        assert_eq!(parsed.resolver, secret.resolver);
414        assert_eq!(parsed.op_ref, secret.op_ref);
415    }
416
417    #[test]
418    fn test_secret_serde_from_json() {
419        let json = r#"{
420            "resolver": "exec",
421            "command": "vault",
422            "args": ["read", "-field=value", "secret/data"]
423        }"#;
424
425        let secret: Secret = serde_json::from_str(json).unwrap();
426        assert_eq!(secret.resolver, "exec");
427        assert_eq!(secret.command, "vault");
428        assert_eq!(secret.args.len(), 3);
429    }
430
431    #[test]
432    fn test_secret_serde_onepassword_ref_field() {
433        // Test that "ref" field is properly deserialized
434        let json = r#"{
435            "resolver": "onepassword",
436            "ref": "op://vault/item/password"
437        }"#;
438
439        let secret: Secret = serde_json::from_str(json).unwrap();
440        assert_eq!(secret.resolver, "onepassword");
441        assert_eq!(secret.op_ref, Some("op://vault/item/password".to_string()));
442    }
443
444    #[test]
445    fn test_secret_serde_extra_fields() {
446        let json = r#"{
447            "resolver": "aws",
448            "command": "",
449            "secret_id": "arn:aws:secretsmanager:us-east-1:123456789:secret:myapp",
450            "region": "us-east-1"
451        }"#;
452
453        let secret: Secret = serde_json::from_str(json).unwrap();
454        assert_eq!(secret.resolver, "aws");
455        assert!(secret.extra.contains_key("secret_id"));
456        assert!(secret.extra.contains_key("region"));
457    }
458
459    #[test]
460    fn test_secret_serde_skip_empty_command() {
461        let secret = Secret::onepassword("op://vault/item/field");
462        let json = serde_json::to_string(&secret).unwrap();
463
464        // Empty command should be skipped
465        assert!(!json.contains("\"command\":"));
466    }
467
468    #[test]
469    fn test_secret_serde_skip_empty_args() {
470        let secret = Secret::onepassword("op://vault/item/field");
471        let json = serde_json::to_string(&secret).unwrap();
472
473        // Empty args should be skipped
474        assert!(!json.contains("\"args\":"));
475    }
476
477    // ==========================================================================
478    // Secret clone/eq tests
479    // ==========================================================================
480
481    #[test]
482    fn test_secret_clone() {
483        let secret = Secret::new("cmd".to_string(), vec!["arg".to_string()]);
484        let cloned = secret.clone();
485
486        assert_eq!(cloned.resolver, secret.resolver);
487        assert_eq!(cloned.command, secret.command);
488        assert_eq!(cloned.args, secret.args);
489    }
490
491    #[test]
492    fn test_secret_eq() {
493        let s1 = Secret::new("cmd".to_string(), vec!["arg".to_string()]);
494        let s2 = Secret::new("cmd".to_string(), vec!["arg".to_string()]);
495        let s3 = Secret::onepassword("op://v/i/f");
496
497        assert_eq!(s1, s2);
498        assert_ne!(s1, s3);
499    }
500
501    #[test]
502    fn test_secret_debug() {
503        let secret = Secret::new("echo".to_string(), vec!["test".to_string()]);
504        let debug = format!("{:?}", secret);
505
506        assert!(debug.contains("Secret"));
507        assert!(debug.contains("exec"));
508        assert!(debug.contains("echo"));
509    }
510}