Skip to main content

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        tracing::debug!(resolver = %self.resolver, op_ref = ?self.op_ref, "Secret::resolve() called");
168        let registry = create_default_registry()?;
169        self.resolve_with_registry(&registry).await
170    }
171
172    /// Resolve the secret value using a custom registry
173    ///
174    /// # Errors
175    /// Returns error if resolution fails
176    pub async fn resolve_with_registry(&self, registry: &SecretRegistry) -> Result<String> {
177        let spec = self.to_spec();
178
179        registry
180            .resolve(&self.resolver, "secret", &spec)
181            .await
182            .map_err(|e| Error::configuration(format!("{e}")))
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use serde_json::json;
190
191    // ==========================================================================
192    // ExecResolver tests
193    // ==========================================================================
194
195    #[test]
196    fn test_exec_resolver_new() {
197        let resolver = ExecResolver {
198            command: "echo".to_string(),
199            args: vec!["hello".to_string()],
200        };
201
202        assert_eq!(resolver.command, "echo");
203        assert_eq!(resolver.args, vec!["hello"]);
204    }
205
206    #[test]
207    fn test_exec_resolver_serde_roundtrip() {
208        let resolver = ExecResolver {
209            command: "/usr/bin/vault".to_string(),
210            args: vec![
211                "read".to_string(),
212                "-field=value".to_string(),
213                "secret/data".to_string(),
214            ],
215        };
216
217        let json = serde_json::to_string(&resolver).unwrap();
218        let parsed: ExecResolver = serde_json::from_str(&json).unwrap();
219
220        assert_eq!(parsed.command, resolver.command);
221        assert_eq!(parsed.args, resolver.args);
222    }
223
224    #[test]
225    fn test_exec_resolver_clone() {
226        let resolver = ExecResolver {
227            command: "cmd".to_string(),
228            args: vec!["arg1".to_string()],
229        };
230
231        let cloned = resolver.clone();
232        assert_eq!(cloned.command, resolver.command);
233        assert_eq!(cloned.args, resolver.args);
234    }
235
236    #[test]
237    fn test_exec_resolver_eq() {
238        let r1 = ExecResolver {
239            command: "cmd".to_string(),
240            args: vec!["arg".to_string()],
241        };
242        let r2 = ExecResolver {
243            command: "cmd".to_string(),
244            args: vec!["arg".to_string()],
245        };
246        let r3 = ExecResolver {
247            command: "other".to_string(),
248            args: vec![],
249        };
250
251        assert_eq!(r1, r2);
252        assert_ne!(r1, r3);
253    }
254
255    // ==========================================================================
256    // Secret construction tests
257    // ==========================================================================
258
259    #[test]
260    fn test_secret_new_exec() {
261        let secret = Secret::new("echo".to_string(), vec!["hello".to_string()]);
262
263        assert_eq!(secret.resolver, "exec");
264        assert_eq!(secret.command, "echo");
265        assert_eq!(secret.args, vec!["hello"]);
266        assert!(secret.op_ref.is_none());
267        assert!(secret.extra.is_empty());
268    }
269
270    #[test]
271    fn test_secret_onepassword() {
272        let secret = Secret::onepassword("op://vault/item/field");
273
274        assert_eq!(secret.resolver, "onepassword");
275        assert_eq!(secret.op_ref, Some("op://vault/item/field".to_string()));
276        assert!(secret.command.is_empty());
277        assert!(secret.args.is_empty());
278    }
279
280    #[test]
281    fn test_secret_onepassword_with_into() {
282        let secret = Secret::onepassword(String::from("op://my-vault/my-item/password"));
283
284        assert_eq!(secret.resolver, "onepassword");
285        assert_eq!(
286            secret.op_ref,
287            Some("op://my-vault/my-item/password".to_string())
288        );
289    }
290
291    #[test]
292    fn test_secret_with_extra() {
293        let mut extra = HashMap::new();
294        extra.insert("region".to_string(), json!("us-east-1"));
295        extra.insert("version".to_string(), json!(2));
296
297        let secret = Secret::with_extra(
298            "aws".to_string(),
299            vec!["secretsmanager".to_string(), "get-secret-value".to_string()],
300            extra.clone(),
301        );
302
303        assert_eq!(secret.resolver, "exec");
304        assert_eq!(secret.command, "aws");
305        assert_eq!(secret.extra, extra);
306    }
307
308    // ==========================================================================
309    // Secret::provider tests
310    // ==========================================================================
311
312    #[test]
313    fn test_secret_provider_exec() {
314        let secret = Secret::new("cmd".to_string(), vec![]);
315        assert_eq!(secret.provider(), "exec");
316    }
317
318    #[test]
319    fn test_secret_provider_onepassword() {
320        let secret = Secret::onepassword("op://vault/item/field");
321        assert_eq!(secret.provider(), "onepassword");
322    }
323
324    #[test]
325    fn test_secret_provider_custom() {
326        let secret = Secret {
327            resolver: "vault".to_string(),
328            command: String::new(),
329            args: Vec::new(),
330            op_ref: None,
331            extra: HashMap::new(),
332        };
333        assert_eq!(secret.provider(), "vault");
334    }
335
336    // ==========================================================================
337    // Secret::to_spec tests
338    // ==========================================================================
339
340    #[test]
341    fn test_secret_to_spec_onepassword() {
342        let secret = Secret::onepassword("op://vault/item/field");
343        let spec = secret.to_spec();
344
345        assert_eq!(spec.source, "op://vault/item/field");
346    }
347
348    #[test]
349    fn test_secret_to_spec_exec() {
350        let secret = Secret::new("echo".to_string(), vec!["hello".to_string()]);
351        let spec = secret.to_spec();
352
353        let source = &spec.source;
354        assert!(source.contains("echo"));
355        assert!(source.contains("hello"));
356    }
357
358    #[test]
359    fn test_secret_to_spec_onepassword_empty_ref() {
360        let secret = Secret {
361            resolver: "onepassword".to_string(),
362            command: String::new(),
363            args: Vec::new(),
364            op_ref: None, // Missing ref
365            extra: HashMap::new(),
366        };
367        let spec = secret.to_spec();
368
369        // Should return empty string for missing ref
370        assert_eq!(spec.source, "");
371    }
372
373    #[test]
374    fn test_secret_to_spec_other_resolver() {
375        let mut extra = HashMap::new();
376        extra.insert("path".to_string(), json!("secret/data/myapp"));
377
378        let secret = Secret {
379            resolver: "vault".to_string(),
380            command: String::new(),
381            args: Vec::new(),
382            op_ref: None,
383            extra,
384        };
385        let spec = secret.to_spec();
386
387        // For other resolvers, the whole secret is serialized
388        let source = &spec.source;
389        assert!(source.contains("vault"));
390        assert!(source.contains("path"));
391    }
392
393    // ==========================================================================
394    // Secret serde tests
395    // ==========================================================================
396
397    #[test]
398    fn test_secret_serde_exec_roundtrip() {
399        let secret = Secret::new("echo".to_string(), vec!["test".to_string()]);
400        let json = serde_json::to_string(&secret).unwrap();
401        let parsed: Secret = serde_json::from_str(&json).unwrap();
402
403        assert_eq!(parsed.resolver, secret.resolver);
404        assert_eq!(parsed.command, secret.command);
405        assert_eq!(parsed.args, secret.args);
406    }
407
408    #[test]
409    fn test_secret_serde_onepassword_roundtrip() {
410        let secret = Secret::onepassword("op://vault/item/field");
411        let json = serde_json::to_string(&secret).unwrap();
412        let parsed: Secret = serde_json::from_str(&json).unwrap();
413
414        assert_eq!(parsed.resolver, secret.resolver);
415        assert_eq!(parsed.op_ref, secret.op_ref);
416    }
417
418    #[test]
419    fn test_secret_serde_from_json() {
420        let json = r#"{
421            "resolver": "exec",
422            "command": "vault",
423            "args": ["read", "-field=value", "secret/data"]
424        }"#;
425
426        let secret: Secret = serde_json::from_str(json).unwrap();
427        assert_eq!(secret.resolver, "exec");
428        assert_eq!(secret.command, "vault");
429        assert_eq!(secret.args.len(), 3);
430    }
431
432    #[test]
433    fn test_secret_serde_onepassword_ref_field() {
434        // Test that "ref" field is properly deserialized
435        let json = r#"{
436            "resolver": "onepassword",
437            "ref": "op://vault/item/password"
438        }"#;
439
440        let secret: Secret = serde_json::from_str(json).unwrap();
441        assert_eq!(secret.resolver, "onepassword");
442        assert_eq!(secret.op_ref, Some("op://vault/item/password".to_string()));
443    }
444
445    #[test]
446    fn test_secret_serde_extra_fields() {
447        let json = r#"{
448            "resolver": "aws",
449            "command": "",
450            "secret_id": "arn:aws:secretsmanager:us-east-1:123456789:secret:myapp",
451            "region": "us-east-1"
452        }"#;
453
454        let secret: Secret = serde_json::from_str(json).unwrap();
455        assert_eq!(secret.resolver, "aws");
456        assert!(secret.extra.contains_key("secret_id"));
457        assert!(secret.extra.contains_key("region"));
458    }
459
460    #[test]
461    fn test_secret_serde_skip_empty_command() {
462        let secret = Secret::onepassword("op://vault/item/field");
463        let json = serde_json::to_string(&secret).unwrap();
464
465        // Empty command should be skipped
466        assert!(!json.contains("\"command\":"));
467    }
468
469    #[test]
470    fn test_secret_serde_skip_empty_args() {
471        let secret = Secret::onepassword("op://vault/item/field");
472        let json = serde_json::to_string(&secret).unwrap();
473
474        // Empty args should be skipped
475        assert!(!json.contains("\"args\":"));
476    }
477
478    // ==========================================================================
479    // Secret clone/eq tests
480    // ==========================================================================
481
482    #[test]
483    fn test_secret_clone() {
484        let secret = Secret::new("cmd".to_string(), vec!["arg".to_string()]);
485        let cloned = secret.clone();
486
487        assert_eq!(cloned.resolver, secret.resolver);
488        assert_eq!(cloned.command, secret.command);
489        assert_eq!(cloned.args, secret.args);
490    }
491
492    #[test]
493    fn test_secret_eq() {
494        let s1 = Secret::new("cmd".to_string(), vec!["arg".to_string()]);
495        let s2 = Secret::new("cmd".to_string(), vec!["arg".to_string()]);
496        let s3 = Secret::onepassword("op://v/i/f");
497
498        assert_eq!(s1, s2);
499        assert_ne!(s1, s3);
500    }
501
502    #[test]
503    fn test_secret_debug() {
504        let secret = Secret::new("echo".to_string(), vec!["test".to_string()]);
505        let debug = format!("{:?}", secret);
506
507        assert!(debug.contains("Secret"));
508        assert!(debug.contains("exec"));
509        assert!(debug.contains("echo"));
510    }
511}