secrets_core/
embedded.rs

1use crate::broker::{BrokerSecret, SecretsBroker};
2use crate::crypto::dek_cache::DekCache;
3use crate::crypto::envelope::EnvelopeService;
4use crate::key_provider::KeyProvider;
5use crate::spec_compat::{
6    ContentType, DecryptError, EncryptionAlgorithm, Error as CoreError, Result as CoreResult,
7    Scope, SecretListItem, SecretMeta, SecretRecord, SecretUri, SecretVersion, SecretsBackend,
8    VersionedSecret, Visibility,
9};
10#[cfg(feature = "nats")]
11use async_nats;
12#[cfg(feature = "nats")]
13use futures::StreamExt;
14use greentic_secrets_support::parse as parse_key;
15use lru::LruCache;
16use serde::de::DeserializeOwned;
17use serde::Serialize;
18use std::collections::HashMap;
19use std::num::NonZeroUsize;
20use std::string::FromUtf8Error;
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23
24/// Errors surfaced by the embedded `SecretsCore` API.
25#[derive(Debug, thiserror::Error)]
26pub enum SecretsError {
27    /// Wrapper for core domain errors.
28    #[error("{0}")]
29    Core(#[from] CoreError),
30    /// Wrapper for decrypt failures.
31    #[error("{0}")]
32    Decrypt(#[from] DecryptError),
33    /// JSON serialisation failure.
34    #[error("{0}")]
35    Json(#[from] serde_json::Error),
36    /// UTF-8 decoding failure.
37    #[error("{0}")]
38    Utf8(#[from] FromUtf8Error),
39    /// Builder validation error.
40    #[error("{0}")]
41    Builder(String),
42}
43
44impl SecretsError {
45    fn not_found(uri: &SecretUri) -> Self {
46        CoreError::NotFound {
47            entity: uri.to_string(),
48        }
49        .into()
50    }
51}
52
53/// Allow/deny policy for embedded access. Currently only `AllowAll`.
54#[derive(Clone, Debug, Default)]
55pub enum Policy {
56    /// Permit every read/write operation.
57    #[default]
58    AllowAll,
59}
60
61impl Policy {
62    fn should_include(&self, _meta: &SecretMeta) -> bool {
63        true
64    }
65}
66
67/// Runtime configuration captured when building a `SecretsCore`.
68pub struct CoreConfig {
69    /// Default tenant scope for the runtime.
70    pub tenant: String,
71    /// Optional team scope for the runtime.
72    pub team: Option<String>,
73    /// Default cache TTL applied to secrets.
74    pub default_ttl: Duration,
75    /// Optional NATS URL for future signalling hooks.
76    pub nats_url: Option<String>,
77    /// Names of the configured backends in iteration order.
78    pub backends: Vec<String>,
79    /// Active policy for evaluation (currently `AllowAll`).
80    pub policy: Policy,
81    /// Maximum number of cached entries retained.
82    pub cache_capacity: usize,
83}
84
85struct BackendRegistration {
86    name: String,
87    backend: Box<dyn SecretsBackend>,
88    key_provider: Box<dyn KeyProvider>,
89}
90
91impl BackendRegistration {
92    fn new<B, K>(name: impl Into<String>, backend: B, key_provider: K) -> Self
93    where
94        B: SecretsBackend + 'static,
95        K: KeyProvider + 'static,
96    {
97        Self {
98            name: name.into(),
99            backend: Box::new(backend),
100            key_provider: Box::new(key_provider),
101        }
102    }
103
104    fn memory() -> Self {
105        Self::new("memory", MemoryBackend::new(), MemoryKeyProvider::default())
106    }
107}
108
109/// Builder for constructing [`SecretsCore`] instances.
110#[derive(Default)]
111pub struct CoreBuilder {
112    tenant: Option<String>,
113    team: Option<String>,
114    default_ttl: Option<Duration>,
115    nats_url: Option<String>,
116    backends: Vec<BackendRegistration>,
117    policy: Option<Policy>,
118    cache_capacity: Option<usize>,
119}
120
121impl CoreBuilder {
122    /// Initialise the builder using environment configuration.
123    ///
124    /// * `GREENTIC_SECRETS_TENANT` sets the default tenant (default: `"default"`).
125    /// * `GREENTIC_SECRETS_TEAM` sets an optional team scope.
126    /// * `GREENTIC_SECRETS_CACHE_TTL_SECS` overrides the cache TTL (default: 300s).
127    /// * `GREENTIC_SECRETS_NATS_URL` records the NATS endpoint (unused today).
128    /// * `GREENTIC_SECRETS_DEV` enables the in-memory backend (default: enabled).
129    pub fn from_env() -> Self {
130        let mut builder = CoreBuilder::default();
131
132        if let Ok(tenant) = std::env::var("GREENTIC_SECRETS_TENANT") {
133            if !tenant.trim().is_empty() {
134                builder.tenant = Some(tenant);
135            }
136        }
137
138        if let Ok(team) = std::env::var("GREENTIC_SECRETS_TEAM") {
139            if !team.trim().is_empty() {
140                builder.team = Some(team);
141            }
142        }
143
144        if let Ok(ttl) = std::env::var("GREENTIC_SECRETS_CACHE_TTL_SECS") {
145            if let Ok(seconds) = ttl.parse::<u64>() {
146                builder.default_ttl = Some(Duration::from_secs(seconds.max(1)));
147            }
148        }
149
150        if let Ok(url) = std::env::var("GREENTIC_SECRETS_NATS_URL") {
151            if !url.trim().is_empty() {
152                builder.nats_url = Some(url);
153            }
154        }
155
156        let dev_enabled = std::env::var("GREENTIC_SECRETS_DEV")
157            .map(|v| matches!(v.as_str(), "1" | "true" | "TRUE"))
158            .unwrap_or(true);
159
160        if dev_enabled {
161            builder.backends.push(BackendRegistration::memory());
162        }
163
164        builder
165    }
166
167    /// Set the tenant scope attached to the runtime.
168    pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
169        self.tenant = Some(tenant.into());
170        self
171    }
172
173    /// Set an optional team scope.
174    pub fn team<T: Into<String>>(mut self, team: T) -> Self {
175        self.team = Some(team.into());
176        self
177    }
178
179    /// Override the default cache TTL.
180    pub fn default_ttl(mut self, ttl: Duration) -> Self {
181        self.default_ttl = Some(ttl);
182        self
183    }
184
185    /// Record an optional NATS URL.
186    pub fn nats_url(mut self, url: impl Into<String>) -> Self {
187        self.nats_url = Some(url.into());
188        self
189    }
190
191    /// Override the cache capacity (number of entries).
192    pub fn cache_capacity(mut self, capacity: usize) -> Self {
193        self.cache_capacity = Some(capacity.max(1));
194        self
195    }
196
197    /// Register a backend with its corresponding key provider.
198    pub fn backend<B, K>(self, backend: B, key_provider: K) -> Self
199    where
200        B: SecretsBackend + 'static,
201        K: KeyProvider + 'static,
202    {
203        self.backend_named("custom", backend, key_provider)
204    }
205
206    /// Register a backend with a specific identifier.
207    pub fn backend_named<B, K>(
208        mut self,
209        name: impl Into<String>,
210        backend: B,
211        key_provider: K,
212    ) -> Self
213    where
214        B: SecretsBackend + 'static,
215        K: KeyProvider + 'static,
216    {
217        self.backends
218            .push(BackendRegistration::new(name, backend, key_provider));
219        self
220    }
221
222    /// Register a backend with the default memory key provider.
223    pub fn with_backend<B>(self, name: impl Into<String>, backend: B) -> Self
224    where
225        B: SecretsBackend + 'static,
226    {
227        self.backend_named(name, backend, MemoryKeyProvider::default())
228    }
229
230    /// Remove any previously registered backends.
231    pub fn clear_backends(&mut self) {
232        self.backends.clear();
233    }
234
235    /// If no backends have been explicitly registered, add sensible defaults.
236    ///
237    /// The current implementation falls back to the environment backend and,
238    /// when configured via `GREENTIC_SECRETS_FILE_ROOT`, the filesystem backend.
239    /// Future revisions will extend this to include cloud provider probes.
240    pub async fn auto_detect_backends(self) -> Self {
241        #[allow(unused_mut)]
242        let mut builder = self;
243        if !builder.backends.is_empty() {
244            return builder;
245        }
246
247        if std::env::var_os("GREENTIC_SECRETS_BACKENDS").is_some() {
248            return builder;
249        }
250
251        if crate::probe::is_kubernetes().await {
252            #[cfg(feature = "k8s")]
253            {
254                builder = builder.backend(
255                    crate::backend::k8s::K8sBackend::new(),
256                    MemoryKeyProvider::default(),
257                );
258            }
259        }
260
261        if crate::probe::is_aws().await {
262            #[cfg(feature = "aws")]
263            {
264                let backend = crate::backend::aws::AwsSecretsManagerBackend::new();
265                builder = builder.backend(backend, MemoryKeyProvider::default());
266            }
267        }
268
269        if crate::probe::is_gcp().await {
270            #[cfg(feature = "gcp")]
271            {
272                let backend = crate::backend::gcp::GcpSecretsManagerBackend::new();
273                builder = builder.backend(backend, MemoryKeyProvider::default());
274            }
275        }
276
277        if crate::probe::is_azure().await {
278            #[cfg(feature = "azure")]
279            {
280                let backend = crate::backend::azure::AzureKeyVaultBackend::new();
281                builder = builder.backend(backend, MemoryKeyProvider::default());
282            }
283        }
284
285        #[cfg(feature = "env")]
286        {
287            builder = builder.backend(
288                crate::backend::env::EnvBackend::new(),
289                MemoryKeyProvider::default(),
290            );
291        }
292
293        #[cfg(feature = "file")]
294        {
295            if let Ok(root) = std::env::var("GREENTIC_SECRETS_FILE_ROOT") {
296                if !root.is_empty() {
297                    builder = builder.backend(
298                        crate::backend::file::FileBackend::new(root),
299                        MemoryKeyProvider::default(),
300                    );
301                }
302            }
303        }
304
305        builder
306    }
307
308    /// Override the policy (currently only `AllowAll` is supported).
309    pub fn policy(mut self, policy: Policy) -> Self {
310        self.policy = Some(policy);
311        self
312    }
313
314    /// Build the [`SecretsCore`] instance.
315    pub async fn build(mut self) -> Result<SecretsCore, SecretsError> {
316        if self.backends.is_empty() {
317            self.backends.push(BackendRegistration::memory());
318        }
319
320        let tenant = self.tenant.unwrap_or_else(|| "default".to_string());
321        let policy = self.policy.unwrap_or_default();
322        let default_ttl = self.default_ttl.unwrap_or_else(|| Duration::from_secs(300));
323        let cache_capacity = self.cache_capacity.unwrap_or(256);
324        let registration = self.backends.remove(0);
325        let backend_names = std::iter::once(registration.name.clone())
326            .chain(self.backends.iter().map(|b| b.name.clone()))
327            .collect();
328
329        let crypto = EnvelopeService::new(
330            registration.key_provider,
331            DekCache::from_env(),
332            EncryptionAlgorithm::Aes256Gcm,
333        );
334        let broker = SecretsBroker::new(registration.backend, crypto);
335
336        let cache =
337            LruCache::new(NonZeroUsize::new(cache_capacity).expect("cache capacity must be > 0"));
338        let cache = Arc::new(Mutex::new(cache));
339
340        let config = CoreConfig {
341            tenant,
342            team: self.team,
343            default_ttl,
344            nats_url: self.nats_url,
345            backends: backend_names,
346            policy: policy.clone(),
347            cache_capacity,
348        };
349
350        let core = SecretsCore {
351            config,
352            broker: Arc::new(Mutex::new(broker)),
353            cache: cache.clone(),
354            cache_ttl: default_ttl,
355            policy,
356        };
357
358        #[cfg(feature = "nats")]
359        if let Some(url) = core.config.nats_url.clone() {
360            spawn_invalidation_listener(cache, core.config.tenant.clone(), url);
361        }
362
363        Ok(core)
364    }
365}
366
367type SharedBroker = Arc<Mutex<SecretsBroker<Box<dyn SecretsBackend>, Box<dyn KeyProvider>>>>;
368
369/// Embedded secrets client that can be used directly from Rust runtimes.
370pub struct SecretsCore {
371    config: CoreConfig,
372    broker: SharedBroker,
373    cache: Arc<Mutex<LruCache<String, CacheEntry>>>,
374    cache_ttl: Duration,
375    policy: Policy,
376}
377
378impl SecretsCore {
379    /// Start building a new embedded core instance.
380    pub fn builder() -> CoreBuilder {
381        CoreBuilder::from_env()
382    }
383
384    /// Access the runtime configuration.
385    /// Return an immutable reference to the runtime configuration.
386    pub fn config(&self) -> &CoreConfig {
387        &self.config
388    }
389
390    /// Retrieve secret bytes for the provided URI.
391    pub async fn get_bytes(&self, uri: &str) -> Result<Vec<u8>, SecretsError> {
392        let uri = self.parse_uri(uri)?;
393        if let Some(bytes) = self.cached_value(&uri) {
394            return Ok(bytes);
395        }
396        let secret = self
397            .fetch_secret(&uri)?
398            .ok_or_else(|| SecretsError::not_found(&uri))?;
399        let value = secret.payload.clone();
400        self.store_cache(uri.to_string(), &secret);
401        Ok(value)
402    }
403
404    /// Retrieve a secret as UTF-8 text.
405    pub async fn get_text(&self, uri: &str) -> Result<String, SecretsError> {
406        let bytes = self.get_bytes(uri).await?;
407        Ok(String::from_utf8(bytes)?)
408    }
409
410    /// Retrieve a secret and deserialize it as JSON.
411    pub async fn get_json<T: DeserializeOwned>(&self, uri: &str) -> Result<T, SecretsError> {
412        let bytes = self.get_bytes(uri).await?;
413        Ok(serde_json::from_slice(&bytes)?)
414    }
415
416    /// Store JSON content at the provided URI.
417    pub async fn put_json<T: Serialize>(
418        &self,
419        uri: &str,
420        value: &T,
421    ) -> Result<SecretMeta, SecretsError> {
422        let uri = self.parse_uri(uri)?;
423        let bytes = serde_json::to_vec(value)?;
424        let mut meta = SecretMeta::new(uri.clone(), Visibility::Team, ContentType::Json);
425        meta.description = None;
426
427        {
428            let mut broker = self.broker.lock().unwrap();
429            broker.put_secret(meta.clone(), &bytes)?;
430        }
431
432        self.store_cache(
433            uri.to_string(),
434            &BrokerSecret {
435                version: 0,
436                meta: meta.clone(),
437                payload: bytes.clone(),
438            },
439        );
440
441        Ok(meta)
442    }
443
444    /// Delete a secret.
445    pub async fn delete(&self, uri: &str) -> Result<(), SecretsError> {
446        let uri = self.parse_uri(uri)?;
447        {
448            let broker = self.broker.lock().unwrap();
449            broker.delete_secret(&uri)?;
450        }
451        let mut cache = self.cache.lock().unwrap();
452        cache.pop(&uri.to_string());
453        Ok(())
454    }
455
456    /// List secret metadata matching the provided prefix.
457    pub async fn list(&self, prefix: &str) -> Result<Vec<SecretMeta>, SecretsError> {
458        let (scope, category_prefix, name_prefix) = parse_prefix(prefix)?;
459        let items: Vec<SecretListItem> = {
460            let broker = self.broker.lock().unwrap();
461            broker.list_secrets(&scope, category_prefix.as_deref(), name_prefix.as_deref())?
462        };
463
464        let mut metas = Vec::with_capacity(items.len());
465        for item in items {
466            let mut meta = SecretMeta::new(item.uri.clone(), item.visibility, item.content_type);
467            meta.description = None;
468            if self.policy.should_include(&meta) {
469                metas.push(meta);
470            }
471        }
472        Ok(metas)
473    }
474
475    fn parse_uri(&self, uri: &str) -> Result<SecretUri, SecretsError> {
476        Ok(parse_key(uri)?)
477    }
478
479    fn cached_value(&self, uri: &SecretUri) -> Option<Vec<u8>> {
480        let key = uri.to_string();
481        let mut cache = self.cache.lock().unwrap();
482        if let Some(entry) = cache.get(&key) {
483            if entry.expires_at > Instant::now() {
484                return Some(entry.value.clone());
485            }
486        }
487        cache.pop(&key);
488        None
489    }
490
491    fn fetch_secret(&self, uri: &SecretUri) -> Result<Option<BrokerSecret>, SecretsError> {
492        let mut broker = self.broker.lock().unwrap();
493        Ok(broker.get_secret(uri)?)
494    }
495
496    fn store_cache(&self, key: String, secret: &BrokerSecret) {
497        let mut cache = self.cache.lock().unwrap();
498        let entry = CacheEntry {
499            value: secret.payload.clone(),
500            meta: secret.meta.clone(),
501            expires_at: Instant::now() + self.cache_ttl,
502        };
503        cache.put(key, entry);
504    }
505
506    /// Remove cached entries whose keys match the provided exact URIs or prefixes
507    /// (indicated by a trailing `*`).
508    #[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
509    pub fn purge_cache(&self, uris: &[String]) {
510        let mut cache = self.cache.lock().unwrap();
511        purge_patterns(&mut cache, uris);
512    }
513}
514
515struct CacheEntry {
516    value: Vec<u8>,
517    #[allow(dead_code)]
518    meta: SecretMeta,
519    expires_at: Instant,
520}
521
522#[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
523fn purge_patterns(cache: &mut LruCache<String, CacheEntry>, patterns: &[String]) {
524    for pattern in patterns {
525        purge_pattern(cache, pattern);
526    }
527}
528
529#[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
530fn purge_pattern(cache: &mut LruCache<String, CacheEntry>, pattern: &str) {
531    if let Some(prefix) = pattern.strip_suffix('*') {
532        let keys: Vec<String> = cache
533            .iter()
534            .filter(|(key, _)| key.starts_with(prefix))
535            .map(|(key, _)| key.clone())
536            .collect();
537        for key in keys {
538            cache.pop(&key);
539        }
540    } else {
541        cache.pop(pattern);
542    }
543}
544
545#[cfg(feature = "nats")]
546fn spawn_invalidation_listener(
547    cache: Arc<Mutex<LruCache<String, CacheEntry>>>,
548    tenant: String,
549    url: String,
550) {
551    let subject = format!("secrets.changed.{tenant}.*");
552    tokio::spawn(async move {
553        if let Ok(client) = async_nats::connect(&url).await {
554            if let Ok(mut sub) = client.subscribe(subject).await {
555                while let Some(msg) = sub.next().await {
556                    if let Ok(payload) = serde_json::from_slice::<InvalidationMessage>(&msg.payload)
557                    {
558                        let mut guard = cache.lock().unwrap();
559                        purge_patterns(&mut guard, &payload.uris);
560                    }
561                }
562            }
563        }
564    });
565}
566
567#[cfg(feature = "nats")]
568#[derive(serde::Deserialize)]
569struct InvalidationMessage {
570    uris: Vec<String>,
571}
572
573/// Simple in-memory backend suitable for embedded usage and tests.
574#[derive(Default)]
575pub struct MemoryBackend {
576    state: Mutex<HashMap<String, Vec<MemoryVersion>>>,
577}
578
579impl MemoryBackend {
580    /// Construct a new empty backend.
581    pub fn new() -> Self {
582        Self::default()
583    }
584}
585
586#[derive(Clone)]
587struct MemoryVersion {
588    version: u64,
589    deleted: bool,
590    record: Option<SecretRecord>,
591}
592
593impl MemoryVersion {
594    fn live(version: u64, record: SecretRecord) -> Self {
595        Self {
596            version,
597            deleted: false,
598            record: Some(record),
599        }
600    }
601
602    fn tombstone(version: u64) -> Self {
603        Self {
604            version,
605            deleted: true,
606            record: None,
607        }
608    }
609
610    fn as_version(&self) -> SecretVersion {
611        SecretVersion {
612            version: self.version,
613            deleted: self.deleted,
614        }
615    }
616
617    fn as_versioned(&self) -> VersionedSecret {
618        VersionedSecret {
619            version: self.version,
620            deleted: self.deleted,
621            record: self.record.clone(),
622        }
623    }
624}
625
626impl SecretsBackend for MemoryBackend {
627    fn put(&self, record: SecretRecord) -> CoreResult<SecretVersion> {
628        let key = record.meta.uri.to_string();
629        let mut guard = self.state.lock().unwrap();
630        let entries = guard.entry(key).or_default();
631        let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
632        entries.push(MemoryVersion::live(next_version, record));
633        Ok(SecretVersion {
634            version: next_version,
635            deleted: false,
636        })
637    }
638
639    fn get(&self, uri: &SecretUri, version: Option<u64>) -> CoreResult<Option<VersionedSecret>> {
640        let key = uri.to_string();
641        let guard = self.state.lock().unwrap();
642        let entries = match guard.get(&key) {
643            Some(entries) => entries,
644            None => return Ok(None),
645        };
646
647        if let Some(target) = version {
648            let entry = entries.iter().find(|entry| entry.version == target);
649            return Ok(entry.cloned().map(|entry| entry.as_versioned()));
650        }
651
652        if matches!(entries.last(), Some(entry) if entry.deleted) {
653            return Ok(None);
654        }
655
656        let latest = entries.iter().rev().find(|entry| !entry.deleted).cloned();
657        Ok(latest.map(|entry| entry.as_versioned()))
658    }
659
660    fn list(
661        &self,
662        scope: &Scope,
663        category_prefix: Option<&str>,
664        name_prefix: Option<&str>,
665    ) -> CoreResult<Vec<SecretListItem>> {
666        let guard = self.state.lock().unwrap();
667        let mut items = Vec::new();
668
669        for versions in guard.values() {
670            if matches!(versions.last(), Some(entry) if entry.deleted) {
671                continue;
672            }
673
674            let latest = match versions.iter().rev().find(|entry| !entry.deleted) {
675                Some(entry) => entry,
676                None => continue,
677            };
678
679            let record = match &latest.record {
680                Some(record) => record,
681                None => continue,
682            };
683
684            let secret_scope = record.meta.scope();
685            if scope.env() != secret_scope.env() || scope.tenant() != secret_scope.tenant() {
686                continue;
687            }
688            if scope.team() != secret_scope.team() {
689                continue;
690            }
691
692            if let Some(prefix) = category_prefix {
693                if !record.meta.uri.category().starts_with(prefix) {
694                    continue;
695                }
696            }
697
698            if let Some(prefix) = name_prefix {
699                if !record.meta.uri.name().starts_with(prefix) {
700                    continue;
701                }
702            }
703
704            items.push(SecretListItem::from_meta(
705                &record.meta,
706                Some(latest.version.to_string()),
707            ));
708        }
709
710        Ok(items)
711    }
712
713    fn delete(&self, uri: &SecretUri) -> CoreResult<SecretVersion> {
714        let key = uri.to_string();
715        let mut guard = self.state.lock().unwrap();
716        let entries = guard.get_mut(&key).ok_or_else(|| CoreError::NotFound {
717            entity: uri.to_string(),
718        })?;
719        let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
720        entries.push(MemoryVersion::tombstone(next_version));
721        Ok(SecretVersion {
722            version: next_version,
723            deleted: true,
724        })
725    }
726
727    fn versions(&self, uri: &SecretUri) -> CoreResult<Vec<SecretVersion>> {
728        let key = uri.to_string();
729        let guard = self.state.lock().unwrap();
730        let entries = guard.get(&key).cloned().unwrap_or_default();
731        Ok(entries
732            .into_iter()
733            .map(|entry| entry.as_version())
734            .collect())
735    }
736
737    fn exists(&self, uri: &SecretUri) -> CoreResult<bool> {
738        let key = uri.to_string();
739        let guard = self.state.lock().unwrap();
740        Ok(guard
741            .get(&key)
742            .and_then(|versions| versions.last())
743            .map(|latest| !latest.deleted)
744            .unwrap_or(false))
745    }
746}
747
748/// Simple in-memory key provider that uses XOR wrapping with per-scope keys.
749#[derive(Default, Clone)]
750pub struct MemoryKeyProvider {
751    keys: Arc<Mutex<HashMap<String, Vec<u8>>>>,
752}
753
754impl MemoryKeyProvider {
755    /// Construct a new provider.
756    pub fn new() -> Self {
757        Self::default()
758    }
759
760    fn key_for_scope(&self, scope: &Scope) -> Vec<u8> {
761        let mut guard = self.keys.lock().unwrap();
762        guard
763            .entry(scope_key(scope))
764            .or_insert_with(|| {
765                let mut buf = vec![0u8; 32];
766                let mut rng = rand::rng();
767                use rand::RngCore;
768                rng.fill_bytes(&mut buf);
769                buf
770            })
771            .clone()
772    }
773}
774
775impl KeyProvider for MemoryKeyProvider {
776    fn wrap_dek(&self, scope: &Scope, dek: &[u8]) -> CoreResult<Vec<u8>> {
777        let key = self.key_for_scope(scope);
778        Ok(xor(&key, dek))
779    }
780
781    fn unwrap_dek(&self, scope: &Scope, wrapped: &[u8]) -> CoreResult<Vec<u8>> {
782        let key = self.key_for_scope(scope);
783        Ok(xor(&key, wrapped))
784    }
785}
786
787fn scope_key(scope: &Scope) -> String {
788    format!(
789        "{}:{}:{}",
790        scope.env(),
791        scope.tenant(),
792        scope.team().unwrap_or("_")
793    )
794}
795
796fn xor(key: &[u8], data: &[u8]) -> Vec<u8> {
797    data.iter()
798        .enumerate()
799        .map(|(idx, byte)| byte ^ key[idx % key.len()])
800        .collect()
801}
802
803fn parse_prefix(prefix: &str) -> Result<(Scope, Option<String>, Option<String>), SecretsError> {
804    const SCHEME: &str = "secrets://";
805    if !prefix.starts_with(SCHEME) {
806        return Err(SecretsError::Builder(
807            "prefix must start with secrets://".into(),
808        ));
809    }
810
811    let rest = &prefix[SCHEME.len()..];
812    let segments: Vec<&str> = rest.split('/').collect();
813    if segments.len() < 3 {
814        return Err(SecretsError::Builder(
815            "prefix must include env/tenant/team segments".into(),
816        ));
817    }
818
819    let env = segments[0];
820    let tenant = segments[1];
821    let team_segment = segments[2];
822    let team = if team_segment == "_" || team_segment.is_empty() {
823        None
824    } else {
825        Some(team_segment.to_string())
826    };
827
828    let scope = Scope::new(env.to_string(), tenant.to_string(), team.clone())?;
829
830    let category_prefix = segments
831        .get(3)
832        .map(|s| s.to_string())
833        .filter(|s| !s.is_empty());
834    let name_prefix = segments
835        .get(4)
836        .map(|s| s.to_string())
837        .filter(|s| !s.is_empty());
838
839    Ok((scope, category_prefix, name_prefix))
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use tokio::time::{sleep, Duration as TokioDuration};
846
847    fn rt() -> tokio::runtime::Runtime {
848        tokio::runtime::Builder::new_current_thread()
849            .enable_time()
850            .build()
851            .unwrap()
852    }
853
854    #[test]
855    fn builder_from_env_defaults() {
856        std::env::remove_var("GREENTIC_SECRETS_TENANT");
857        std::env::remove_var("GREENTIC_SECRETS_TEAM");
858        std::env::remove_var("GREENTIC_SECRETS_CACHE_TTL_SECS");
859        std::env::remove_var("GREENTIC_SECRETS_NATS_URL");
860
861        let builder = CoreBuilder::from_env();
862        assert!(builder.tenant.is_none());
863        assert_eq!(builder.backends.len(), 1);
864    }
865
866    #[test]
867    fn roundtrip_put_get_json() {
868        rt().block_on(async {
869            let core = SecretsCore::builder()
870                .backend(MemoryBackend::new(), MemoryKeyProvider::default())
871                .build()
872                .await
873                .unwrap();
874
875            let uri = "secrets://dev/acme/_/configs/service";
876            let payload = serde_json::json!({ "token": "secret" });
877            let meta = core.put_json(uri, &payload).await.unwrap();
878            assert_eq!(meta.uri.to_string(), uri);
879
880            let value: serde_json::Value = core.get_json(uri).await.unwrap();
881            assert_eq!(value, payload);
882        });
883    }
884
885    #[test]
886    fn cache_hit_and_expiry() {
887        rt().block_on(async {
888            let ttl = Duration::from_millis(50);
889            let core = SecretsCore::builder()
890                .default_ttl(ttl)
891                .backend(MemoryBackend::new(), MemoryKeyProvider::default())
892                .build()
893                .await
894                .unwrap();
895
896            let uri = "secrets://dev/acme/_/configs/cache";
897            core.put_json(uri, &serde_json::json!({"key": "value"}))
898                .await
899                .unwrap();
900
901            // Populate cache
902            core.get_bytes(uri).await.unwrap();
903            let key = uri.to_string();
904            {
905                let cache = core.cache.lock().unwrap();
906                assert!(cache.peek(&key).is_some());
907            }
908
909            // Hit should keep entry
910            core.get_bytes(uri).await.unwrap();
911            {
912                let cache = core.cache.lock().unwrap();
913                assert!(cache.peek(&key).is_some());
914            }
915
916            sleep(TokioDuration::from_millis(75)).await;
917
918            core.get_bytes(uri).await.unwrap();
919            {
920                let cache = core.cache.lock().unwrap();
921                let entry = cache.peek(&key).unwrap();
922                assert!(entry.expires_at > Instant::now());
923            }
924        });
925    }
926
927    #[test]
928    fn cache_invalidation_patterns() {
929        rt().block_on(async {
930            let core = SecretsCore::builder()
931                .backend(MemoryBackend::new(), MemoryKeyProvider::default())
932                .build()
933                .await
934                .unwrap();
935
936            let uri_a = "secrets://dev/acme/_/configs/app";
937            let uri_b = "secrets://dev/acme/_/configs/db";
938
939            let record = serde_json::json!({"value": 1});
940            core.put_json(uri_a, &record).await.unwrap();
941            core.put_json(uri_b, &record).await.unwrap();
942
943            // Ensure entries are cached.
944            core.get_bytes(uri_a).await.unwrap();
945            core.get_bytes(uri_b).await.unwrap();
946
947            core.purge_cache(&[uri_a.to_string()]);
948
949            assert!(core
950                .cached_value(&SecretUri::try_from(uri_a).unwrap())
951                .is_none());
952            assert!(core
953                .cached_value(&SecretUri::try_from(uri_b).unwrap())
954                .is_some());
955
956            core.purge_cache(&["secrets://dev/acme/_/configs/*".to_string()]);
957            assert!(core
958                .cached_value(&SecretUri::try_from(uri_b).unwrap())
959                .is_none());
960        });
961    }
962
963    #[test]
964    fn auto_detect_skips_when_backends_present() {
965        std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
966        rt().block_on(async {
967            let builder =
968                CoreBuilder::default().backend(MemoryBackend::new(), MemoryKeyProvider::default());
969            let builder = builder.auto_detect_backends().await;
970            let core = builder.build().await.unwrap();
971            assert_eq!(core.config().backends.len(), 1);
972            assert_eq!(core.config().backends[0], "custom");
973        });
974    }
975
976    #[test]
977    fn auto_detect_respects_backends_env_override() {
978        std::env::set_var("GREENTIC_SECRETS_BACKENDS", "aws");
979        std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
980        rt().block_on(async {
981            let builder = CoreBuilder::default().auto_detect_backends().await;
982            let core = builder.build().await.unwrap();
983            assert_eq!(core.config().backends, vec!["memory".to_string()]);
984        });
985        std::env::remove_var("GREENTIC_SECRETS_BACKENDS");
986    }
987}