skill_context/providers/
env.rs

1//! Environment variable secret provider.
2//!
3//! This provider reads secrets from environment variables, which is useful
4//! for CI/CD environments where secrets are injected as environment variables.
5//!
6//! This provider is **read-only** - secrets can only be read, not written or deleted.
7
8use async_trait::async_trait;
9use zeroize::Zeroizing;
10
11use super::{SecretProvider, SecretValue};
12use crate::ContextError;
13
14/// Secret provider that reads from environment variables.
15///
16/// Secrets are looked up using the pattern: `{PREFIX}{CONTEXT}_{KEY}`
17/// where context and key are converted to uppercase with `-` replaced by `_`.
18///
19/// # Example
20///
21/// With prefix `SECRET_` and context `my-context`, key `api-key`:
22/// - Environment variable: `SECRET_MY_CONTEXT_API_KEY`
23pub struct EnvironmentProvider {
24    /// Prefix for environment variable names.
25    prefix: String,
26}
27
28impl EnvironmentProvider {
29    /// Create a new environment provider with the given prefix.
30    ///
31    /// # Example
32    ///
33    /// ```rust
34    /// use skill_context::providers::EnvironmentProvider;
35    ///
36    /// let provider = EnvironmentProvider::new("SECRET_");
37    /// ```
38    pub fn new(prefix: impl Into<String>) -> Self {
39        Self {
40            prefix: prefix.into(),
41        }
42    }
43
44    /// Create a provider with no prefix.
45    pub fn without_prefix() -> Self {
46        Self {
47            prefix: String::new(),
48        }
49    }
50
51    /// Build the environment variable name for a secret.
52    fn build_env_var(&self, context_id: &str, key: &str) -> String {
53        let context_part = context_id.to_uppercase().replace('-', "_").replace('.', "_");
54        let key_part = key.to_uppercase().replace('-', "_").replace('.', "_");
55        format!("{}{}__{}", self.prefix, context_part, key_part)
56    }
57}
58
59#[async_trait]
60impl SecretProvider for EnvironmentProvider {
61    async fn get_secret(
62        &self,
63        context_id: &str,
64        key: &str,
65    ) -> Result<Option<SecretValue>, ContextError> {
66        let env_var = self.build_env_var(context_id, key);
67
68        match std::env::var(&env_var) {
69            Ok(value) => {
70                tracing::debug!(
71                    context_id = context_id,
72                    key = key,
73                    env_var = env_var,
74                    "Retrieved secret from environment"
75                );
76                Ok(Some(Zeroizing::new(value)))
77            }
78            Err(std::env::VarError::NotPresent) => Ok(None),
79            Err(std::env::VarError::NotUnicode(_)) => {
80                tracing::warn!(
81                    context_id = context_id,
82                    key = key,
83                    env_var = env_var,
84                    "Environment variable contains invalid UTF-8"
85                );
86                Err(ContextError::SecretProvider(format!(
87                    "Environment variable '{}' contains invalid UTF-8",
88                    env_var
89                )))
90            }
91        }
92    }
93
94    async fn set_secret(
95        &self,
96        context_id: &str,
97        key: &str,
98        _value: &str,
99    ) -> Result<(), ContextError> {
100        let env_var = self.build_env_var(context_id, key);
101        tracing::warn!(
102            context_id = context_id,
103            key = key,
104            env_var = env_var,
105            "Attempted to set secret via environment provider (read-only)"
106        );
107        Err(ContextError::SecretProvider(
108            "Environment provider is read-only. Cannot set secrets.".to_string(),
109        ))
110    }
111
112    async fn delete_secret(&self, context_id: &str, key: &str) -> Result<(), ContextError> {
113        let env_var = self.build_env_var(context_id, key);
114        tracing::warn!(
115            context_id = context_id,
116            key = key,
117            env_var = env_var,
118            "Attempted to delete secret via environment provider (read-only)"
119        );
120        Err(ContextError::SecretProvider(
121            "Environment provider is read-only. Cannot delete secrets.".to_string(),
122        ))
123    }
124
125    async fn list_keys(&self, context_id: &str) -> Result<Vec<String>, ContextError> {
126        let context_prefix = format!(
127            "{}{}__",
128            self.prefix,
129            context_id.to_uppercase().replace('-', "_").replace('.', "_")
130        );
131
132        let keys: Vec<String> = std::env::vars()
133            .filter_map(|(k, _)| {
134                if k.starts_with(&context_prefix) {
135                    Some(
136                        k[context_prefix.len()..]
137                            .to_lowercase()
138                            .replace('_', "-"),
139                    )
140                } else {
141                    None
142                }
143            })
144            .collect();
145
146        Ok(keys)
147    }
148
149    fn name(&self) -> &'static str {
150        "environment"
151    }
152
153    fn is_read_only(&self) -> bool {
154        true
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_env_var_naming() {
164        let provider = EnvironmentProvider::new("SECRET_");
165
166        assert_eq!(
167            provider.build_env_var("my-context", "api-key"),
168            "SECRET_MY_CONTEXT__API_KEY"
169        );
170
171        assert_eq!(
172            provider.build_env_var("production.api", "database.password"),
173            "SECRET_PRODUCTION_API__DATABASE_PASSWORD"
174        );
175    }
176
177    #[test]
178    fn test_no_prefix() {
179        let provider = EnvironmentProvider::without_prefix();
180
181        assert_eq!(
182            provider.build_env_var("context", "key"),
183            "CONTEXT__KEY"
184        );
185    }
186
187    #[tokio::test]
188    async fn test_get_from_env() {
189        let provider = EnvironmentProvider::new("TEST_SECRET_");
190
191        // Set env var for test
192        std::env::set_var("TEST_SECRET_MY_CTX__MY_KEY", "my-secret-value");
193
194        let result = provider.get_secret("my-ctx", "my-key").await.unwrap();
195        assert!(result.is_some());
196        assert_eq!(&*result.unwrap(), "my-secret-value");
197
198        // Clean up
199        std::env::remove_var("TEST_SECRET_MY_CTX__MY_KEY");
200    }
201
202    #[tokio::test]
203    async fn test_get_nonexistent() {
204        let provider = EnvironmentProvider::new("NONEXISTENT_PREFIX_");
205
206        let result = provider
207            .get_secret("context", "key")
208            .await
209            .unwrap();
210
211        assert!(result.is_none());
212    }
213
214    #[tokio::test]
215    async fn test_set_is_read_only() {
216        let provider = EnvironmentProvider::new("TEST_");
217
218        let result = provider.set_secret("ctx", "key", "value").await;
219
220        assert!(result.is_err());
221        assert!(provider.is_read_only());
222    }
223
224    #[tokio::test]
225    async fn test_delete_is_read_only() {
226        let provider = EnvironmentProvider::new("TEST_");
227
228        let result = provider.delete_secret("ctx", "key").await;
229
230        assert!(result.is_err());
231    }
232
233    #[tokio::test]
234    async fn test_list_keys() {
235        let provider = EnvironmentProvider::new("TEST_LIST_");
236
237        // Set some env vars
238        std::env::set_var("TEST_LIST_MY_CTX__KEY_ONE", "value1");
239        std::env::set_var("TEST_LIST_MY_CTX__KEY_TWO", "value2");
240        std::env::set_var("TEST_LIST_OTHER_CTX__KEY", "value3");
241
242        let keys = provider.list_keys("my-ctx").await.unwrap();
243
244        assert!(keys.contains(&"key-one".to_string()));
245        assert!(keys.contains(&"key-two".to_string()));
246        assert!(!keys.contains(&"key".to_string())); // Wrong context
247
248        // Clean up
249        std::env::remove_var("TEST_LIST_MY_CTX__KEY_ONE");
250        std::env::remove_var("TEST_LIST_MY_CTX__KEY_TWO");
251        std::env::remove_var("TEST_LIST_OTHER_CTX__KEY");
252    }
253}