Skip to main content

zlayer_secrets/
provider.rs

1//! Secrets provider traits and resolver for `ZLayer` secrets management.
2//!
3//! This module defines the core abstractions for secrets storage and retrieval:
4//!
5//! - [`SecretsProvider`]: Read-only access to secrets
6//! - [`SecretsStore`]: Read-write access to secrets (extends `SecretsProvider`)
7//! - [`SecretsResolver`]: Resolves secret references (`$S:`) in configuration values
8
9use async_trait::async_trait;
10use std::collections::HashMap;
11use tracing::instrument;
12
13use crate::{Result, RotationResult, Secret, SecretMetadata, SecretRef, SecretsError};
14
15/// Read-only secrets provider trait.
16///
17/// Implementations provide access to secrets from various backends such as
18/// encrypted local storage, `HashiCorp` Vault, AWS Secrets Manager, etc.
19///
20/// # Scoping
21///
22/// Secrets are organized by scope, which is typically a deployment or service
23/// identifier. The scope determines the namespace for secret lookups.
24///
25/// # Example
26///
27/// ```rust,ignore
28/// use zlayer_secrets::{SecretsProvider, Secret};
29///
30/// async fn get_database_password(provider: &impl SecretsProvider) -> Result<Secret> {
31///     provider.get_secret("my-deployment", "database-password").await
32/// }
33/// ```
34#[async_trait]
35pub trait SecretsProvider: Send + Sync {
36    /// Retrieve a single secret by scope and name.
37    ///
38    /// # Arguments
39    ///
40    /// * `scope` - The scope identifier (e.g., deployment name)
41    /// * `name` - The secret name within the scope
42    ///
43    /// # Errors
44    ///
45    /// Returns `SecretsError::NotFound` if the secret doesn't exist,
46    /// or other errors for storage/access issues.
47    async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret>;
48
49    /// Retrieve multiple secrets by scope and names.
50    ///
51    /// This method enables efficient batch retrieval when multiple secrets
52    /// are needed. Implementations may optimize this by fetching all secrets
53    /// in a single request where the backend supports it.
54    ///
55    /// # Arguments
56    ///
57    /// * `scope` - The scope identifier (e.g., deployment name)
58    /// * `names` - Slice of secret names to retrieve
59    ///
60    /// # Returns
61    ///
62    /// A map of secret names to their values. Secrets that don't exist
63    /// are omitted from the result rather than causing an error.
64    async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>>;
65
66    /// List metadata for all secrets in a scope.
67    ///
68    /// This returns metadata (name, version, timestamps) without exposing
69    /// the actual secret values. Useful for inventory and auditing.
70    ///
71    /// # Arguments
72    ///
73    /// * `scope` - The scope identifier to list secrets from
74    async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>>;
75
76    /// Check if a secret exists in the given scope.
77    ///
78    /// This is more efficient than `get_secret` when you only need to
79    /// verify existence without retrieving the value.
80    ///
81    /// # Arguments
82    ///
83    /// * `scope` - The scope identifier
84    /// * `name` - The secret name to check
85    async fn exists(&self, scope: &str, name: &str) -> Result<bool>;
86}
87
88/// Read-write secrets store trait.
89///
90/// Extends [`SecretsProvider`] with write operations for managing secrets.
91/// Implementations handle encryption, versioning, and storage.
92///
93/// # Example
94///
95/// ```rust,ignore
96/// use zlayer_secrets::{SecretsStore, Secret};
97///
98/// async fn store_api_key(store: &impl SecretsStore, key: &str) -> Result<()> {
99///     let secret = Secret::new(key);
100///     store.set_secret("my-deployment", "api-key", &secret).await
101/// }
102/// ```
103#[async_trait]
104pub trait SecretsStore: SecretsProvider {
105    /// Store or update a secret.
106    ///
107    /// If the secret already exists, it will be updated and its version
108    /// incremented. If it doesn't exist, a new secret will be created.
109    ///
110    /// # Arguments
111    ///
112    /// * `scope` - The scope identifier (e.g., deployment name)
113    /// * `name` - The secret name within the scope
114    /// * `value` - The secret value to store
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if encryption fails or storage is unavailable.
119    async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()>;
120
121    /// Delete a secret from the store.
122    ///
123    /// # Arguments
124    ///
125    /// * `scope` - The scope identifier
126    /// * `name` - The secret name to delete
127    ///
128    /// # Errors
129    ///
130    /// Returns `SecretsError::NotFound` if the secret doesn't exist,
131    /// or other errors for storage issues.
132    async fn delete_secret(&self, scope: &str, name: &str) -> Result<()>;
133
134    /// Rotate a secret: overwrite with a new value and return the version before+after.
135    ///
136    /// Default impl reads current metadata, writes the new value, re-reads metadata
137    /// to capture the new version. Backends MAY override for efficiency.
138    ///
139    /// # Arguments
140    ///
141    /// * `scope` - The scope identifier
142    /// * `name` - The secret name
143    /// * `value` - The new secret value
144    ///
145    /// # Errors
146    ///
147    /// Returns [`SecretsError::NotFound`] if the secret does not exist (use `set_secret` to create).
148    /// Other storage errors as usual.
149    async fn rotate_secret(
150        &self,
151        scope: &str,
152        name: &str,
153        value: &Secret,
154    ) -> Result<RotationResult> {
155        let previous_version = self
156            .list_secrets(scope)
157            .await?
158            .into_iter()
159            .find(|m| m.name == name)
160            .map(|m| m.version);
161        if previous_version.is_none() {
162            return Err(SecretsError::NotFound {
163                name: name.to_string(),
164            });
165        }
166        self.set_secret(scope, name, value).await?;
167        let new_version = self
168            .list_secrets(scope)
169            .await?
170            .into_iter()
171            .find(|m| m.name == name)
172            .map(|m| m.version)
173            .ok_or_else(|| SecretsError::NotFound {
174                name: name.to_string(),
175            })?;
176        Ok(RotationResult {
177            previous_version,
178            new_version,
179        })
180    }
181}
182
183// ---------------------------------------------------------------------------
184// Blanket impls for Arc<T> - allows shared ownership of providers/stores
185// ---------------------------------------------------------------------------
186
187#[async_trait]
188impl<T: SecretsProvider + ?Sized> SecretsProvider for std::sync::Arc<T> {
189    async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
190        (**self).get_secret(scope, name).await
191    }
192
193    async fn get_secrets(&self, scope: &str, names: &[&str]) -> Result<HashMap<String, Secret>> {
194        (**self).get_secrets(scope, names).await
195    }
196
197    async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
198        (**self).list_secrets(scope).await
199    }
200
201    async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
202        (**self).exists(scope, name).await
203    }
204}
205
206#[async_trait]
207impl<T: SecretsStore + ?Sized> SecretsStore for std::sync::Arc<T> {
208    async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
209        (**self).set_secret(scope, name, value).await
210    }
211
212    async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
213        (**self).delete_secret(scope, name).await
214    }
215
216    async fn rotate_secret(
217        &self,
218        scope: &str,
219        name: &str,
220        value: &Secret,
221    ) -> Result<RotationResult> {
222        (**self).rotate_secret(scope, name, value).await
223    }
224}
225
226/// Resolves an environment name-or-id to the scope string used by the
227/// underlying [`SecretsStore`].
228///
229/// Typically backed by the API's `EnvironmentStorage` plus the `env_scope(..)`
230/// helper, which produces strings of the form `env:{env_id}` (global) or
231/// `project:{pid}:env:{env_id}` (project-scoped).
232///
233/// This trait is consumed by [`SecretsResolver`] when resolving the
234/// `$secret://<env>/<KEY>` URL-like reference form.
235#[async_trait]
236pub trait EnvScopeProvider: Send + Sync {
237    /// Return the scope string for the given env (name or id).
238    ///
239    /// # Errors
240    ///
241    /// Returns an error if the env does not exist or cannot be resolved.
242    async fn resolve_env_scope(&self, name_or_id: &str) -> Result<String>;
243}
244
245/// Resolver for secret references in configuration values.
246///
247/// The resolver parses `$S:` and `$secret://` prefixed values and replaces
248/// them with actual secret values from the underlying provider. This enables
249/// declarative secret references in deployment configurations.
250///
251/// # Syntax
252///
253/// - `$S:name` - Deployment-level secret
254/// - `$S:@service/name` - Service-level secret
255/// - `$S:name/field` - Field extraction from structured (JSON) secrets
256/// - `$secret://<env>/<KEY>` - Environment-scoped secret lookup
257/// - `$secret://<env>/<KEY>/<field>` - With JSON field extraction
258///
259/// The `$secret://` form requires an [`EnvScopeProvider`] to be wired up via
260/// [`SecretsResolver::with_env_resolver`]; otherwise such references fail with
261/// a clear error.
262///
263/// # Example
264///
265/// ```rust,ignore
266/// use zlayer_secrets::{SecretsResolver, PersistentSecretsStore};
267/// use std::collections::HashMap;
268///
269/// async fn resolve_env_vars(
270///     store: PersistentSecretsStore,
271///     env: HashMap<String, String>,
272/// ) -> Result<HashMap<String, String>> {
273///     let resolver = SecretsResolver::new(store, "my-deployment");
274///     resolver.resolve_env(&env).await
275/// }
276/// ```
277pub struct SecretsResolver<P: SecretsProvider> {
278    provider: P,
279    scope: String,
280    env_resolver: Option<std::sync::Arc<dyn EnvScopeProvider>>,
281}
282
283impl<P: SecretsProvider> SecretsResolver<P> {
284    /// Create a new secrets resolver.
285    ///
286    /// # Arguments
287    ///
288    /// * `provider` - The secrets provider to use for lookups
289    /// * `scope` - The default scope (deployment name) for resolving secrets
290    pub fn new(provider: P, scope: impl Into<String>) -> Self {
291        Self {
292            provider,
293            scope: scope.into(),
294            env_resolver: None,
295        }
296    }
297
298    /// Attach an [`EnvScopeProvider`] to enable resolution of
299    /// `$secret://<env>/<KEY>` references.
300    ///
301    /// Without this, any `$secret://` reference will fail with a clear error.
302    #[must_use]
303    pub fn with_env_resolver(mut self, env_resolver: std::sync::Arc<dyn EnvScopeProvider>) -> Self {
304        self.env_resolver = Some(env_resolver);
305        self
306    }
307
308    /// Get a reference to the underlying provider.
309    pub fn provider(&self) -> &P {
310        &self.provider
311    }
312
313    /// Get the configured scope.
314    pub fn scope(&self) -> &str {
315        &self.scope
316    }
317
318    /// Resolve a single value that may contain a secret reference.
319    ///
320    /// If the value starts with `$S:`, it will be parsed and replaced with
321    /// the actual secret value. Otherwise, the original value is returned.
322    ///
323    /// # Arguments
324    ///
325    /// * `value` - The value to potentially resolve
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if the value is a secret reference but:
330    /// - The reference syntax is invalid
331    /// - The secret doesn't exist
332    /// - Field extraction fails (for JSON secrets)
333    #[instrument(skip(self), fields(scope = %self.scope))]
334    pub async fn resolve_value(&self, value: &str) -> Result<String> {
335        // New URL-like form: $secret://<env>/<KEY>[/<field>]
336        if let Some(rest) = value.strip_prefix("$secret://") {
337            return self.resolve_secret_url(rest).await;
338        }
339
340        // Existing $S:... form
341        if SecretRef::is_secret_ref(value) {
342            return self.resolve_s_ref(value).await;
343        }
344
345        // Not a secret reference, return as-is
346        Ok(value.to_string())
347    }
348
349    /// Resolve the existing `$S:` style reference (deployment-scoped or
350    /// `@service/`-scoped, with optional `/field` JSON extraction).
351    async fn resolve_s_ref(&self, value: &str) -> Result<String> {
352        // Parse the reference
353        let secret_ref = SecretRef::parse(value).ok_or_else(|| SecretsError::InvalidName {
354            name: value.to_string(),
355        })?;
356
357        // Determine the scope based on service qualifier
358        let scope = match &secret_ref.service {
359            Some(service) => format!("{}/{}", self.scope, service),
360            None => self.scope.clone(),
361        };
362
363        // Fetch the secret
364        let secret = self.provider.get_secret(&scope, &secret_ref.name).await?;
365        let secret_value = secret.expose();
366
367        // Handle field extraction if specified
368        match &secret_ref.field {
369            Some(field) => Self::extract_field(secret_value, field),
370            None => Ok(secret_value.to_string()),
371        }
372    }
373
374    /// Resolve a `$secret://<env>/<KEY>[/<field>]` reference.
375    ///
376    /// `rest` is the portion of the value after the `$secret://` prefix.
377    async fn resolve_secret_url(&self, rest: &str) -> Result<String> {
378        // Split off the env component.
379        let (env_name, after_env) =
380            rest.split_once('/')
381                .ok_or_else(|| SecretsError::InvalidName {
382                    name: format!("$secret://{rest}"),
383                })?;
384
385        if env_name.is_empty() {
386            return Err(SecretsError::InvalidName {
387                name: format!("$secret://{rest}"),
388            });
389        }
390
391        // Remainder is either "KEY" or "KEY/field".
392        let (key, field) = match after_env.split_once('/') {
393            Some((k, f)) => (k, Some(f.to_string())),
394            None => (after_env, None),
395        };
396
397        if key.is_empty() {
398            return Err(SecretsError::InvalidName {
399                name: format!("$secret://{rest}"),
400            });
401        }
402
403        let env_resolver = self.env_resolver.as_ref().ok_or_else(|| {
404            SecretsError::Provider(
405                "SecretsResolver has no env resolver; `$secret://` not supported".to_string(),
406            )
407        })?;
408
409        let scope = env_resolver.resolve_env_scope(env_name).await?;
410        let secret = self.provider.get_secret(&scope, key).await?;
411        let secret_value = secret.expose();
412
413        match field {
414            Some(f) => Self::extract_field(secret_value, &f),
415            None => Ok(secret_value.to_string()),
416        }
417    }
418
419    /// Resolve all secret references in a map of environment variables.
420    ///
421    /// This method efficiently batches secret lookups by:
422    /// 1. Scanning all values to identify secret references
423    /// 2. Grouping references by scope
424    /// 3. Fetching secrets in batches per scope
425    /// 4. Resolving all values with the fetched secrets
426    ///
427    /// # Arguments
428    ///
429    /// * `env` - Map of environment variable names to values
430    ///
431    /// # Returns
432    ///
433    /// A new map with all secret references replaced by their actual values.
434    /// Non-secret values are passed through unchanged.
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if any secret reference is invalid, or if a referenced
439    /// secret cannot be found.
440    #[instrument(skip(self, env), fields(scope = %self.scope, env_count = env.len()))]
441    pub async fn resolve_env(
442        &self,
443        env: &HashMap<String, String>,
444    ) -> Result<HashMap<String, String>> {
445        // First pass: identify all secret references and group by scope
446        let mut refs_by_scope: HashMap<String, Vec<(String, SecretRef)>> = HashMap::new();
447        let mut non_secret_entries: Vec<(String, String)> = Vec::new();
448
449        for (key, value) in env {
450            if SecretRef::is_secret_ref(value) {
451                if let Some(secret_ref) = SecretRef::parse(value) {
452                    let scope = match &secret_ref.service {
453                        Some(service) => format!("{}/{}", self.scope, service),
454                        None => self.scope.clone(),
455                    };
456                    refs_by_scope
457                        .entry(scope)
458                        .or_default()
459                        .push((key.clone(), secret_ref));
460                } else {
461                    return Err(SecretsError::InvalidName {
462                        name: value.clone(),
463                    });
464                }
465            } else {
466                non_secret_entries.push((key.clone(), value.clone()));
467            }
468        }
469
470        // Batch fetch secrets by scope
471        let mut secrets_by_scope: HashMap<String, HashMap<String, Secret>> = HashMap::new();
472
473        for (scope, refs) in &refs_by_scope {
474            let names: Vec<&str> = refs
475                .iter()
476                .map(|(_, secret_ref)| secret_ref.name.as_str())
477                .collect();
478
479            // Deduplicate names for the batch request
480            let unique_names: Vec<&str> = names
481                .iter()
482                .copied()
483                .collect::<std::collections::HashSet<_>>()
484                .into_iter()
485                .collect();
486
487            let secrets = self.provider.get_secrets(scope, &unique_names).await?;
488            secrets_by_scope.insert(scope.clone(), secrets);
489        }
490
491        // Build the resolved environment map
492        let mut resolved = HashMap::with_capacity(env.len());
493
494        // Add non-secret entries directly
495        for (key, value) in non_secret_entries {
496            resolved.insert(key, value);
497        }
498
499        // Resolve secret references
500        for (scope, refs) in refs_by_scope {
501            let scope_secrets = secrets_by_scope.get(&scope).ok_or_else(|| {
502                SecretsError::Provider(format!("missing secrets for scope: {scope}"))
503            })?;
504
505            for (env_key, secret_ref) in refs {
506                let secret =
507                    scope_secrets
508                        .get(&secret_ref.name)
509                        .ok_or_else(|| SecretsError::NotFound {
510                            name: secret_ref.name.clone(),
511                        })?;
512
513                let value = match &secret_ref.field {
514                    Some(field) => Self::extract_field(secret.expose(), field)?,
515                    None => secret.expose().to_string(),
516                };
517
518                resolved.insert(env_key, value);
519            }
520        }
521
522        Ok(resolved)
523    }
524
525    /// Extract a field from a JSON-formatted secret value.
526    fn extract_field(secret_value: &str, field: &str) -> Result<String> {
527        let json: serde_json::Value = serde_json::from_str(secret_value)
528            .map_err(|e| SecretsError::Decryption(e.to_string()))?;
529
530        match json.get(field) {
531            Some(serde_json::Value::String(s)) => Ok(s.clone()),
532            Some(serde_json::Value::Number(n)) => Ok(n.to_string()),
533            Some(serde_json::Value::Bool(b)) => Ok(b.to_string()),
534            Some(serde_json::Value::Null) => Ok(String::new()),
535            Some(v) => Ok(v.to_string()), // For arrays/objects, return JSON string
536            None => Err(SecretsError::NotFound {
537                name: format!("field '{field}' in secret"),
538            }),
539        }
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use std::collections::HashMap;
547    use std::sync::Mutex;
548
549    /// Mock provider for testing
550    struct MockProvider {
551        secrets: Mutex<HashMap<String, HashMap<String, Secret>>>,
552    }
553
554    impl MockProvider {
555        fn new() -> Self {
556            Self {
557                secrets: Mutex::new(HashMap::new()),
558            }
559        }
560
561        fn add_secret(&self, scope: &str, name: &str, value: &str) {
562            let mut secrets = self.secrets.lock().unwrap();
563            secrets
564                .entry(scope.to_string())
565                .or_default()
566                .insert(name.to_string(), Secret::new(value));
567        }
568    }
569
570    #[async_trait]
571    impl SecretsProvider for MockProvider {
572        async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
573            let secrets = self.secrets.lock().unwrap();
574            secrets
575                .get(scope)
576                .and_then(|s| s.get(name))
577                .cloned()
578                .ok_or_else(|| SecretsError::NotFound {
579                    name: name.to_string(),
580                })
581        }
582
583        async fn get_secrets(
584            &self,
585            scope: &str,
586            names: &[&str],
587        ) -> Result<HashMap<String, Secret>> {
588            let secrets = self.secrets.lock().unwrap();
589            let scope_secrets = secrets.get(scope);
590
591            let mut result = HashMap::new();
592            if let Some(scope_secrets) = scope_secrets {
593                for name in names {
594                    if let Some(secret) = scope_secrets.get(*name) {
595                        result.insert((*name).to_string(), secret.clone());
596                    }
597                }
598            }
599            Ok(result)
600        }
601
602        async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
603            let secrets = self.secrets.lock().unwrap();
604            Ok(secrets
605                .get(scope)
606                .map(|s| s.keys().map(SecretMetadata::new).collect())
607                .unwrap_or_default())
608        }
609
610        async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
611            let secrets = self.secrets.lock().unwrap();
612            Ok(secrets.get(scope).is_some_and(|s| s.contains_key(name)))
613        }
614    }
615
616    #[tokio::test]
617    async fn test_resolve_non_secret_value() {
618        let provider = MockProvider::new();
619        let resolver = SecretsResolver::new(provider, "test-deployment");
620
621        let result = resolver.resolve_value("plain-value").await.unwrap();
622        assert_eq!(result, "plain-value");
623    }
624
625    #[tokio::test]
626    async fn test_resolve_secret_value() {
627        let provider = MockProvider::new();
628        provider.add_secret("test-deployment", "api-key", "secret-api-key-123");
629
630        let resolver = SecretsResolver::new(provider, "test-deployment");
631
632        let result = resolver.resolve_value("$S:api-key").await.unwrap();
633        assert_eq!(result, "secret-api-key-123");
634    }
635
636    #[tokio::test]
637    async fn test_resolve_service_scoped_secret() {
638        let provider = MockProvider::new();
639        provider.add_secret("test-deployment/api", "db-password", "service-specific-pwd");
640
641        let resolver = SecretsResolver::new(provider, "test-deployment");
642
643        let result = resolver.resolve_value("$S:@api/db-password").await.unwrap();
644        assert_eq!(result, "service-specific-pwd");
645    }
646
647    #[tokio::test]
648    async fn test_resolve_secret_with_field() {
649        let provider = MockProvider::new();
650        provider.add_secret(
651            "test-deployment",
652            "database",
653            r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
654        );
655
656        let resolver = SecretsResolver::new(provider, "test-deployment");
657
658        let result = resolver
659            .resolve_value("$S:database/password")
660            .await
661            .unwrap();
662        assert_eq!(result, "db-secret");
663
664        // Test numeric field
665        let provider = MockProvider::new();
666        provider.add_secret(
667            "test-deployment",
668            "database",
669            r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
670        );
671        let resolver = SecretsResolver::new(provider, "test-deployment");
672
673        let result = resolver.resolve_value("$S:database/port").await.unwrap();
674        assert_eq!(result, "5432");
675    }
676
677    #[tokio::test]
678    async fn test_resolve_missing_secret() {
679        let provider = MockProvider::new();
680        let resolver = SecretsResolver::new(provider, "test-deployment");
681
682        let result = resolver.resolve_value("$S:nonexistent").await;
683        assert!(result.is_err());
684        assert!(matches!(result.unwrap_err(), SecretsError::NotFound { .. }));
685    }
686
687    #[tokio::test]
688    async fn test_resolve_env() {
689        let provider = MockProvider::new();
690        provider.add_secret("test-deployment", "api-key", "secret-key");
691        provider.add_secret("test-deployment", "db-password", "secret-pwd");
692        provider.add_secret("test-deployment/worker", "worker-token", "worker-secret");
693
694        let resolver = SecretsResolver::new(provider, "test-deployment");
695
696        let mut env = HashMap::new();
697        env.insert("API_KEY".to_string(), "$S:api-key".to_string());
698        env.insert("DB_PASSWORD".to_string(), "$S:db-password".to_string());
699        env.insert(
700            "WORKER_TOKEN".to_string(),
701            "$S:@worker/worker-token".to_string(),
702        );
703        env.insert("PLAIN_VAR".to_string(), "plain-value".to_string());
704
705        let resolved_env = resolver.resolve_env(&env).await.unwrap();
706
707        assert_eq!(resolved_env.get("API_KEY").unwrap(), "secret-key");
708        assert_eq!(resolved_env.get("DB_PASSWORD").unwrap(), "secret-pwd");
709        assert_eq!(resolved_env.get("WORKER_TOKEN").unwrap(), "worker-secret");
710        assert_eq!(resolved_env.get("PLAIN_VAR").unwrap(), "plain-value");
711    }
712
713    #[tokio::test]
714    async fn test_resolve_env_with_missing_secret() {
715        let provider = MockProvider::new();
716        provider.add_secret("test-deployment", "exists", "value");
717
718        let resolver = SecretsResolver::new(provider, "test-deployment");
719
720        let mut env = HashMap::new();
721        env.insert("EXISTS".to_string(), "$S:exists".to_string());
722        env.insert("MISSING".to_string(), "$S:does-not-exist".to_string());
723
724        let result = resolver.resolve_env(&env).await;
725        assert!(result.is_err());
726    }
727
728    #[tokio::test]
729    async fn test_provider_exists() {
730        let provider = MockProvider::new();
731        provider.add_secret("scope", "exists", "value");
732
733        assert!(provider.exists("scope", "exists").await.unwrap());
734        assert!(!provider.exists("scope", "missing").await.unwrap());
735        assert!(!provider.exists("other-scope", "exists").await.unwrap());
736    }
737
738    #[tokio::test]
739    async fn test_provider_list_secrets() {
740        let provider = MockProvider::new();
741        provider.add_secret("scope", "secret1", "value1");
742        provider.add_secret("scope", "secret2", "value2");
743        provider.add_secret("other", "secret3", "value3");
744
745        let list = provider.list_secrets("scope").await.unwrap();
746        assert_eq!(list.len(), 2);
747
748        let names: Vec<&str> = list.iter().map(|m| m.name.as_str()).collect();
749        assert!(names.contains(&"secret1"));
750        assert!(names.contains(&"secret2"));
751    }
752
753    #[tokio::test]
754    async fn test_resolver_accessors() {
755        let provider = MockProvider::new();
756        let resolver = SecretsResolver::new(provider, "my-scope");
757
758        assert_eq!(resolver.scope(), "my-scope");
759        // Verify provider is accessible
760        let _ = resolver.provider();
761    }
762
763    /// Mock store that tracks versioned metadata, for exercising `SecretsStore`
764    /// default methods like `rotate_secret`.
765    type MockStoreData = Mutex<HashMap<String, HashMap<String, (Secret, u32)>>>;
766
767    struct MockStore {
768        // scope -> name -> (Secret, version)
769        data: MockStoreData,
770    }
771
772    impl MockStore {
773        fn new() -> Self {
774            Self {
775                data: Mutex::new(HashMap::new()),
776            }
777        }
778    }
779
780    #[async_trait]
781    impl SecretsProvider for MockStore {
782        async fn get_secret(&self, scope: &str, name: &str) -> Result<Secret> {
783            let data = self.data.lock().unwrap();
784            data.get(scope)
785                .and_then(|s| s.get(name))
786                .map(|(secret, _)| secret.clone())
787                .ok_or_else(|| SecretsError::NotFound {
788                    name: name.to_string(),
789                })
790        }
791
792        async fn get_secrets(
793            &self,
794            scope: &str,
795            names: &[&str],
796        ) -> Result<HashMap<String, Secret>> {
797            let data = self.data.lock().unwrap();
798            let mut result = HashMap::new();
799            if let Some(scope_data) = data.get(scope) {
800                for name in names {
801                    if let Some((secret, _)) = scope_data.get(*name) {
802                        result.insert((*name).to_string(), secret.clone());
803                    }
804                }
805            }
806            Ok(result)
807        }
808
809        async fn list_secrets(&self, scope: &str) -> Result<Vec<SecretMetadata>> {
810            let data = self.data.lock().unwrap();
811            Ok(data
812                .get(scope)
813                .map(|s| {
814                    s.iter()
815                        .map(|(name, (_, version))| {
816                            let mut meta = SecretMetadata::new(name);
817                            meta.version = *version;
818                            meta
819                        })
820                        .collect()
821                })
822                .unwrap_or_default())
823        }
824
825        async fn exists(&self, scope: &str, name: &str) -> Result<bool> {
826            let data = self.data.lock().unwrap();
827            Ok(data.get(scope).is_some_and(|s| s.contains_key(name)))
828        }
829    }
830
831    #[async_trait]
832    impl SecretsStore for MockStore {
833        async fn set_secret(&self, scope: &str, name: &str, value: &Secret) -> Result<()> {
834            let mut data = self.data.lock().unwrap();
835            let scope_data = data.entry(scope.to_string()).or_default();
836            let next_version = scope_data
837                .get(name)
838                .map_or(1, |(_, version)| version.saturating_add(1));
839            scope_data.insert(name.to_string(), (value.clone(), next_version));
840            Ok(())
841        }
842
843        async fn delete_secret(&self, scope: &str, name: &str) -> Result<()> {
844            let mut data = self.data.lock().unwrap();
845            let scope_data = data.get_mut(scope).ok_or_else(|| SecretsError::NotFound {
846                name: name.to_string(),
847            })?;
848            scope_data
849                .remove(name)
850                .ok_or_else(|| SecretsError::NotFound {
851                    name: name.to_string(),
852                })?;
853            Ok(())
854        }
855    }
856
857    #[tokio::test]
858    async fn test_rotate_secret_default_impl() {
859        let store = MockStore::new();
860        let scope = "test-scope";
861        let name = "test-key";
862
863        // Initial write: version 1
864        store
865            .set_secret(scope, name, &Secret::new("v1"))
866            .await
867            .unwrap();
868
869        // Rotate to v2
870        let result = store
871            .rotate_secret(scope, name, &Secret::new("v2"))
872            .await
873            .unwrap();
874
875        assert_eq!(result.previous_version, Some(1));
876        assert_eq!(result.new_version, 2);
877
878        // Verify the stored value is now v2
879        let current = store.get_secret(scope, name).await.unwrap();
880        assert_eq!(current.expose(), "v2");
881    }
882
883    #[tokio::test]
884    async fn test_rotate_secret_missing_returns_not_found() {
885        let store = MockStore::new();
886        let result = store
887            .rotate_secret("scope", "does-not-exist", &Secret::new("v1"))
888            .await;
889        assert!(matches!(result, Err(SecretsError::NotFound { .. })));
890    }
891
892    // -----------------------------------------------------------------------
893    // `$secret://` URL-form reference tests
894    // -----------------------------------------------------------------------
895
896    /// Simple in-memory env-scope resolver used by the `$secret://` tests.
897    ///
898    /// Maps an env name-or-id to the scope string used by the underlying
899    /// provider (e.g. `"bootstrap"` -> `"env:abc"`).
900    struct MockEnvScope {
901        map: HashMap<String, String>,
902    }
903
904    impl MockEnvScope {
905        fn new(pairs: &[(&str, &str)]) -> Self {
906            Self {
907                map: pairs
908                    .iter()
909                    .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
910                    .collect(),
911            }
912        }
913    }
914
915    #[async_trait]
916    impl EnvScopeProvider for MockEnvScope {
917        async fn resolve_env_scope(&self, name_or_id: &str) -> Result<String> {
918            self.map
919                .get(name_or_id)
920                .cloned()
921                .ok_or_else(|| SecretsError::NotFound {
922                    name: format!("env:{name_or_id}"),
923                })
924        }
925    }
926
927    #[tokio::test]
928    async fn test_secret_url_resolves_via_env_resolver() {
929        let provider = MockProvider::new();
930        provider.add_secret("env:abc", "PWD", "xyz");
931
932        let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
933
934        let resolver =
935            SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
936
937        let result = resolver
938            .resolve_value("$secret://bootstrap/PWD")
939            .await
940            .unwrap();
941        assert_eq!(result, "xyz");
942    }
943
944    #[tokio::test]
945    async fn test_secret_url_without_env_resolver_errors() {
946        let provider = MockProvider::new();
947        provider.add_secret("env:abc", "PWD", "xyz");
948
949        // No with_env_resolver() call.
950        let resolver = SecretsResolver::new(provider, "ignored-scope");
951
952        let err = resolver
953            .resolve_value("$secret://bootstrap/PWD")
954            .await
955            .unwrap_err();
956
957        match err {
958            SecretsError::Provider(msg) => {
959                assert!(
960                    msg.contains("$secret://"),
961                    "expected error to mention `$secret://`, got: {msg}"
962                );
963            }
964            other => panic!("expected SecretsError::Provider, got {other:?}"),
965        }
966    }
967
968    #[tokio::test]
969    async fn test_secret_url_with_json_field_extraction() {
970        let provider = MockProvider::new();
971        provider.add_secret(
972            "env:abc",
973            "database",
974            r#"{"host":"localhost","port":5432,"password":"db-secret"}"#,
975        );
976
977        let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
978
979        let resolver =
980            SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
981
982        // String field
983        let pwd = resolver
984            .resolve_value("$secret://bootstrap/database/password")
985            .await
986            .unwrap();
987        assert_eq!(pwd, "db-secret");
988
989        // Numeric field coerced to its string form
990        let port = resolver
991            .resolve_value("$secret://bootstrap/database/port")
992            .await
993            .unwrap();
994        assert_eq!(port, "5432");
995    }
996
997    #[tokio::test]
998    async fn test_secret_url_malformed_missing_key_errors() {
999        let provider = MockProvider::new();
1000        let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
1001        let resolver =
1002            SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
1003
1004        // No `/KEY` component at all.
1005        let err = resolver
1006            .resolve_value("$secret://bootstrap")
1007            .await
1008            .unwrap_err();
1009        assert!(matches!(err, SecretsError::InvalidName { .. }));
1010
1011        // Trailing slash, empty key.
1012        let err = resolver
1013            .resolve_value("$secret://bootstrap/")
1014            .await
1015            .unwrap_err();
1016        assert!(matches!(err, SecretsError::InvalidName { .. }));
1017    }
1018
1019    #[tokio::test]
1020    async fn test_secret_url_unknown_env_propagates_not_found() {
1021        let provider = MockProvider::new();
1022        let env_resolver = std::sync::Arc::new(MockEnvScope::new(&[("bootstrap", "env:abc")]));
1023        let resolver =
1024            SecretsResolver::new(provider, "ignored-scope").with_env_resolver(env_resolver);
1025
1026        let err = resolver
1027            .resolve_value("$secret://unknown-env/PWD")
1028            .await
1029            .unwrap_err();
1030        assert!(matches!(err, SecretsError::NotFound { .. }));
1031    }
1032}