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