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