Skip to main content

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