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 raw bytes at the provided URI under the given content type.
449    ///
450    /// The symmetric write counterpart to [`get_bytes`](Self::get_bytes): the
451    /// payload is persisted verbatim (envelope-encrypted by the broker) and
452    /// read back byte-for-byte, with no serialization framing. Use it to seed a
453    /// value a consumer reads raw — e.g. a provider token — where the JSON
454    /// quoting [`put_json`](Self::put_json) adds would corrupt the round-trip.
455    pub async fn put_bytes(
456        &self,
457        uri: &str,
458        value: &[u8],
459        content_type: ContentType,
460    ) -> Result<SecretMeta, SecretsError> {
461        let uri = self.parse_uri(uri)?;
462        self.ensure_scope_allowed(uri.scope())?;
463        let mut meta = SecretMeta::new(uri.clone(), Visibility::Team, content_type);
464        meta.description = None;
465
466        {
467            let mut broker = self.broker.lock().unwrap();
468            broker.put_secret(meta.clone(), value)?;
469        }
470
471        self.store_cache(
472            uri.to_string(),
473            &BrokerSecret {
474                version: 0,
475                meta: meta.clone(),
476                payload: value.to_vec(),
477            },
478        );
479
480        Ok(meta)
481    }
482
483    /// Store UTF-8 text at the provided URI. Convenience wrapper over
484    /// [`put_bytes`](Self::put_bytes) with [`ContentType::Text`]; round-trips
485    /// byte-for-byte with [`get_text`](Self::get_text).
486    pub async fn put_text(&self, uri: &str, value: &str) -> Result<SecretMeta, SecretsError> {
487        self.put_bytes(uri, value.as_bytes(), ContentType::Text)
488            .await
489    }
490
491    /// Store JSON content at the provided URI. Serializes `value` and stores it
492    /// via [`put_bytes`](Self::put_bytes) under [`ContentType::Json`].
493    pub async fn put_json<T: Serialize>(
494        &self,
495        uri: &str,
496        value: &T,
497    ) -> Result<SecretMeta, SecretsError> {
498        let bytes = serde_json::to_vec(value)?;
499        self.put_bytes(uri, &bytes, ContentType::Json).await
500    }
501
502    /// Delete a secret.
503    pub async fn delete(&self, uri: &str) -> Result<(), SecretsError> {
504        let uri = self.parse_uri(uri)?;
505        self.ensure_scope_allowed(uri.scope())?;
506        {
507            let broker = self.broker.lock().unwrap();
508            broker.delete_secret(&uri)?;
509        }
510        let mut cache = self.cache.lock().unwrap();
511        cache.pop(&uri.to_string());
512        Ok(())
513    }
514
515    /// List secret metadata matching the provided prefix.
516    pub async fn list(&self, prefix: &str) -> Result<Vec<SecretMeta>, SecretsError> {
517        let (scope, category_prefix, name_prefix) = parse_prefix(prefix)?;
518        self.ensure_scope_allowed(&scope)?;
519        let items: Vec<SecretListItem> = {
520            let broker = self.broker.lock().unwrap();
521            broker.list_secrets(&scope, category_prefix.as_deref(), name_prefix.as_deref())?
522        };
523
524        let mut metas = Vec::with_capacity(items.len());
525        for item in items {
526            let mut meta = SecretMeta::new(item.uri.clone(), item.visibility, item.content_type);
527            meta.description = None;
528            if self.policy.should_include(&meta) {
529                metas.push(meta);
530            }
531        }
532        Ok(metas)
533    }
534
535    fn parse_uri(&self, uri: &str) -> Result<SecretUri, SecretsError> {
536        Ok(SecretUri::parse(uri)?)
537    }
538
539    fn cached_value(&self, uri: &SecretUri) -> Option<Vec<u8>> {
540        let key = uri.to_string();
541        let mut cache = self.cache.lock().unwrap();
542        if let Some(entry) = cache.get(&key)
543            && entry.expires_at > Instant::now()
544        {
545            return Some(entry.value.clone());
546        }
547        cache.pop(&key);
548        None
549    }
550
551    fn fetch_secret(&self, uri: &SecretUri) -> Result<Option<BrokerSecret>, SecretsError> {
552        let mut broker = self.broker.lock().unwrap();
553        Ok(broker.get_secret(uri)?)
554    }
555
556    fn store_cache(&self, key: String, secret: &BrokerSecret) {
557        let mut cache = self.cache.lock().unwrap();
558        let entry = CacheEntry {
559            value: secret.payload.clone(),
560            meta: secret.meta.clone(),
561            expires_at: Instant::now() + self.cache_ttl,
562        };
563        cache.put(key, entry);
564    }
565
566    fn ensure_scope_allowed(&self, scope: &Scope) -> Result<(), SecretsError> {
567        if scope.tenant() != self.config.tenant {
568            return Err(SecretsError::Builder(format!(
569                "tenant `{}` is not permitted for this runtime (allowed tenant: `{}`)",
570                scope.tenant(),
571                self.config.tenant
572            )));
573        }
574
575        if let Some(expected_team) = self.config.team.as_ref() {
576            match scope.team() {
577                Some(team) if team == expected_team => Ok(()),
578                Some(team) => Err(SecretsError::Builder(format!(
579                    "team `{team}` is not permitted for this runtime (allowed team: `{expected_team}`)"
580                ))),
581                None => Ok(()),
582            }
583        } else {
584            Ok(())
585        }
586    }
587
588    /// Remove cached entries whose keys match the provided exact URIs or prefixes
589    /// (indicated by a trailing `*`).
590    #[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
591    pub fn purge_cache(&self, uris: &[String]) {
592        let mut cache = self.cache.lock().unwrap();
593        purge_patterns(&mut cache, uris);
594    }
595}
596
597struct CacheEntry {
598    value: Vec<u8>,
599    #[allow(dead_code)]
600    meta: SecretMeta,
601    expires_at: Instant,
602}
603
604#[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
605fn purge_patterns(cache: &mut LruCache<String, CacheEntry>, patterns: &[String]) {
606    for pattern in patterns {
607        purge_pattern(cache, pattern);
608    }
609}
610
611#[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
612fn purge_pattern(cache: &mut LruCache<String, CacheEntry>, pattern: &str) {
613    if let Some(prefix) = pattern.strip_suffix('*') {
614        let keys: Vec<String> = cache
615            .iter()
616            .filter(|(key, _)| key.starts_with(prefix))
617            .map(|(key, _)| key.clone())
618            .collect();
619        for key in keys {
620            cache.pop(&key);
621        }
622    } else {
623        cache.pop(pattern);
624    }
625}
626
627#[cfg(feature = "nats")]
628fn spawn_invalidation_listener(
629    cache: Arc<Mutex<LruCache<String, CacheEntry>>>,
630    tenant: String,
631    url: String,
632) {
633    let subject = format!("secrets.changed.{tenant}.*");
634    tokio::spawn(async move {
635        if let Ok(client) = async_nats::connect(&url).await
636            && let Ok(mut sub) = client.subscribe(subject).await
637        {
638            while let Some(msg) = sub.next().await {
639                if let Ok(payload) = serde_json::from_slice::<InvalidationMessage>(&msg.payload) {
640                    let mut guard = cache.lock().unwrap();
641                    purge_patterns(&mut guard, &payload.uris);
642                }
643            }
644        }
645    });
646}
647
648#[cfg(feature = "nats")]
649#[derive(serde::Deserialize)]
650struct InvalidationMessage {
651    uris: Vec<String>,
652}
653
654/// Simple in-memory backend suitable for embedded usage and tests.
655#[derive(Default)]
656pub struct MemoryBackend {
657    state: Mutex<HashMap<String, Vec<MemoryVersion>>>,
658}
659
660impl MemoryBackend {
661    /// Construct a new empty backend.
662    pub fn new() -> Self {
663        Self::default()
664    }
665}
666
667#[derive(Clone)]
668struct MemoryVersion {
669    version: u64,
670    deleted: bool,
671    record: Option<SecretRecord>,
672}
673
674impl MemoryVersion {
675    fn live(version: u64, record: SecretRecord) -> Self {
676        Self {
677            version,
678            deleted: false,
679            record: Some(record),
680        }
681    }
682
683    fn tombstone(version: u64) -> Self {
684        Self {
685            version,
686            deleted: true,
687            record: None,
688        }
689    }
690
691    fn as_version(&self) -> SecretVersion {
692        SecretVersion {
693            version: self.version,
694            deleted: self.deleted,
695        }
696    }
697
698    fn as_versioned(&self) -> VersionedSecret {
699        VersionedSecret {
700            version: self.version,
701            deleted: self.deleted,
702            record: self.record.clone(),
703        }
704    }
705}
706
707impl SecretsBackend for MemoryBackend {
708    fn put(&self, record: SecretRecord) -> CoreResult<SecretVersion> {
709        let key = record.meta.uri.to_string();
710        let mut guard = self.state.lock().unwrap();
711        let entries = guard.entry(key).or_default();
712        let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
713        entries.push(MemoryVersion::live(next_version, record));
714        Ok(SecretVersion {
715            version: next_version,
716            deleted: false,
717        })
718    }
719
720    fn get(&self, uri: &SecretUri, version: Option<u64>) -> CoreResult<Option<VersionedSecret>> {
721        let key = uri.to_string();
722        let guard = self.state.lock().unwrap();
723        let entries = match guard.get(&key) {
724            Some(entries) => entries,
725            None => return Ok(None),
726        };
727
728        if let Some(target) = version {
729            let entry = entries.iter().find(|entry| entry.version == target);
730            return Ok(entry.cloned().map(|entry| entry.as_versioned()));
731        }
732
733        if matches!(entries.last(), Some(entry) if entry.deleted) {
734            return Ok(None);
735        }
736
737        let latest = entries.iter().rev().find(|entry| !entry.deleted).cloned();
738        Ok(latest.map(|entry| entry.as_versioned()))
739    }
740
741    fn list(
742        &self,
743        scope: &Scope,
744        category_prefix: Option<&str>,
745        name_prefix: Option<&str>,
746    ) -> CoreResult<Vec<SecretListItem>> {
747        let guard = self.state.lock().unwrap();
748        let mut items = Vec::new();
749
750        for versions in guard.values() {
751            if matches!(versions.last(), Some(entry) if entry.deleted) {
752                continue;
753            }
754
755            let latest = match versions.iter().rev().find(|entry| !entry.deleted) {
756                Some(entry) => entry,
757                None => continue,
758            };
759
760            let record = match &latest.record {
761                Some(record) => record,
762                None => continue,
763            };
764
765            let secret_scope = record.meta.scope();
766            if scope.env() != secret_scope.env() || scope.tenant() != secret_scope.tenant() {
767                continue;
768            }
769            if scope.team() != secret_scope.team() {
770                continue;
771            }
772
773            if let Some(prefix) = category_prefix
774                && !record.meta.uri.category().starts_with(prefix)
775            {
776                continue;
777            }
778
779            if let Some(prefix) = name_prefix
780                && !record.meta.uri.name().starts_with(prefix)
781            {
782                continue;
783            }
784
785            items.push(SecretListItem::from_meta(
786                &record.meta,
787                Some(latest.version.to_string()),
788            ));
789        }
790
791        Ok(items)
792    }
793
794    fn delete(&self, uri: &SecretUri) -> CoreResult<SecretVersion> {
795        let key = uri.to_string();
796        let mut guard = self.state.lock().unwrap();
797        let entries = guard.get_mut(&key).ok_or_else(|| CoreError::NotFound {
798            entity: uri.to_string(),
799        })?;
800        let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
801        entries.push(MemoryVersion::tombstone(next_version));
802        Ok(SecretVersion {
803            version: next_version,
804            deleted: true,
805        })
806    }
807
808    fn versions(&self, uri: &SecretUri) -> CoreResult<Vec<SecretVersion>> {
809        let key = uri.to_string();
810        let guard = self.state.lock().unwrap();
811        let entries = guard.get(&key).cloned().unwrap_or_default();
812        Ok(entries
813            .into_iter()
814            .map(|entry| entry.as_version())
815            .collect())
816    }
817
818    fn exists(&self, uri: &SecretUri) -> CoreResult<bool> {
819        let key = uri.to_string();
820        let guard = self.state.lock().unwrap();
821        Ok(guard
822            .get(&key)
823            .and_then(|versions| versions.last())
824            .map(|latest| !latest.deleted)
825            .unwrap_or(false))
826    }
827}
828
829/// Simple in-memory key provider that uses XOR wrapping with per-scope keys.
830#[derive(Default, Clone)]
831pub struct MemoryKeyProvider {
832    keys: Arc<Mutex<HashMap<String, Vec<u8>>>>,
833}
834
835impl MemoryKeyProvider {
836    /// Construct a new provider.
837    pub fn new() -> Self {
838        Self::default()
839    }
840
841    fn key_for_scope(&self, scope: &Scope) -> Vec<u8> {
842        let mut guard = self.keys.lock().unwrap();
843        guard
844            .entry(scope_key(scope))
845            .or_insert_with(|| {
846                let mut buf = vec![0u8; 32];
847                let mut rng = rand::rng();
848                use rand::Rng;
849                rng.fill_bytes(&mut buf);
850                buf
851            })
852            .clone()
853    }
854}
855
856impl KeyProvider for MemoryKeyProvider {
857    fn wrap_dek(&self, scope: &Scope, dek: &[u8]) -> CoreResult<Vec<u8>> {
858        let key = self.key_for_scope(scope);
859        Ok(xor(&key, dek))
860    }
861
862    fn unwrap_dek(&self, scope: &Scope, wrapped: &[u8]) -> CoreResult<Vec<u8>> {
863        let key = self.key_for_scope(scope);
864        Ok(xor(&key, wrapped))
865    }
866}
867
868fn scope_key(scope: &Scope) -> String {
869    format!(
870        "{}:{}:{}",
871        scope.env(),
872        scope.tenant(),
873        scope.team().unwrap_or("_")
874    )
875}
876
877fn xor(key: &[u8], data: &[u8]) -> Vec<u8> {
878    data.iter()
879        .enumerate()
880        .map(|(idx, byte)| byte ^ key[idx % key.len()])
881        .collect()
882}
883
884fn parse_prefix(prefix: &str) -> Result<(Scope, Option<String>, Option<String>), SecretsError> {
885    const SCHEME: &str = "secrets://";
886    if !prefix.starts_with(SCHEME) {
887        return Err(SecretsError::Builder(
888            "prefix must start with secrets://".into(),
889        ));
890    }
891
892    let rest = &prefix[SCHEME.len()..];
893    let segments: Vec<&str> = rest.split('/').collect();
894    if segments.len() < 3 {
895        return Err(SecretsError::Builder(
896            "prefix must include env/tenant/team segments".into(),
897        ));
898    }
899
900    let env = segments[0];
901    let tenant = segments[1];
902    let team_segment = segments[2];
903    let team = if team_segment == "_" || team_segment.is_empty() {
904        None
905    } else {
906        Some(team_segment.to_string())
907    };
908
909    let scope = Scope::new(env.to_string(), tenant.to_string(), team.clone())?;
910
911    let category_prefix = segments
912        .get(3)
913        .map(|s| s.to_string())
914        .filter(|s| !s.is_empty());
915    let name_prefix = segments
916        .get(4)
917        .map(|s| s.to_string())
918        .filter(|s| !s.is_empty());
919
920    Ok((scope, category_prefix, name_prefix))
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use tokio::time::{Duration as TokioDuration, sleep};
927
928    fn rt() -> tokio::runtime::Runtime {
929        tokio::runtime::Builder::new_current_thread()
930            .enable_time()
931            .build()
932            .unwrap()
933    }
934
935    #[test]
936    fn builder_from_env_defaults() {
937        unsafe {
938            std::env::remove_var("GREENTIC_SECRETS_TENANT");
939            std::env::remove_var("GREENTIC_SECRETS_TEAM");
940            std::env::remove_var("GREENTIC_SECRETS_CACHE_TTL_SECS");
941            std::env::remove_var("GREENTIC_SECRETS_NATS_URL");
942        }
943
944        let builder = CoreBuilder::from_env();
945        assert!(builder.tenant.is_none());
946        assert!(builder.backends.is_empty());
947        assert!(builder.dev_backend_enabled);
948
949        rt().block_on(async {
950            let core = builder.build().await.unwrap();
951            assert_eq!(core.config().backends.len(), 1);
952        });
953    }
954
955    #[test]
956    fn roundtrip_put_get_json() {
957        rt().block_on(async {
958            let core = SecretsCore::builder()
959                .tenant("acme")
960                .backend(MemoryBackend::new(), MemoryKeyProvider::default())
961                .build()
962                .await
963                .unwrap();
964
965            let uri = "secrets://dev/acme/_/configs/service";
966            let payload = serde_json::json!({ "token": "secret" });
967            let meta = core.put_json(uri, &payload).await.unwrap();
968            assert_eq!(meta.uri.to_string(), uri);
969
970            let value: serde_json::Value = core.get_json(uri).await.unwrap();
971            assert_eq!(value, payload);
972        });
973    }
974
975    #[test]
976    fn put_bytes_roundtrips_raw_without_json_framing() {
977        rt().block_on(async {
978            let core = SecretsCore::builder()
979                .tenant("acme")
980                .backend(MemoryBackend::new(), MemoryKeyProvider::default())
981                .build()
982                .await
983                .unwrap();
984
985            // A provider token the runtime reads back raw via `get_bytes`.
986            let uri = "secrets://dev/acme/_/messaging-telegram/telegram_bot_token";
987            let token = "123:AA-bb_cc";
988            core.put_text(uri, token).await.unwrap();
989            assert_eq!(core.get_bytes(uri).await.unwrap(), token.as_bytes());
990            assert_eq!(core.get_text(uri).await.unwrap(), token);
991
992            // `put_json` would frame the same string with quotes — exactly the
993            // corruption `put_bytes`/`put_text` avoids for a raw consumer.
994            let json_uri = "secrets://dev/acme/_/messaging-telegram/json_token";
995            core.put_json(json_uri, &token).await.unwrap();
996            assert_eq!(core.get_bytes(json_uri).await.unwrap(), b"\"123:AA-bb_cc\"");
997        });
998    }
999
1000    #[test]
1001    fn cache_hit_and_expiry() {
1002        rt().block_on(async {
1003            let ttl = Duration::from_millis(50);
1004            let core = SecretsCore::builder()
1005                .tenant("acme")
1006                .default_ttl(ttl)
1007                .backend(MemoryBackend::new(), MemoryKeyProvider::default())
1008                .build()
1009                .await
1010                .unwrap();
1011
1012            let uri = "secrets://dev/acme/_/configs/cache";
1013            core.put_json(uri, &serde_json::json!({"key": "value"}))
1014                .await
1015                .unwrap();
1016
1017            // Populate cache
1018            core.get_bytes(uri).await.unwrap();
1019            let key = uri.to_string();
1020            {
1021                let cache = core.cache.lock().unwrap();
1022                assert!(cache.peek(&key).is_some());
1023            }
1024
1025            // Hit should keep entry
1026            core.get_bytes(uri).await.unwrap();
1027            {
1028                let cache = core.cache.lock().unwrap();
1029                assert!(cache.peek(&key).is_some());
1030            }
1031
1032            sleep(TokioDuration::from_millis(75)).await;
1033
1034            core.get_bytes(uri).await.unwrap();
1035            {
1036                let cache = core.cache.lock().unwrap();
1037                let entry = cache.peek(&key).unwrap();
1038                assert!(entry.expires_at > Instant::now());
1039            }
1040        });
1041    }
1042
1043    #[test]
1044    fn cache_invalidation_patterns() {
1045        rt().block_on(async {
1046            let core = SecretsCore::builder()
1047                .tenant("acme")
1048                .backend(MemoryBackend::new(), MemoryKeyProvider::default())
1049                .build()
1050                .await
1051                .unwrap();
1052
1053            let uri_a = "secrets://dev/acme/_/configs/app";
1054            let uri_b = "secrets://dev/acme/_/configs/db";
1055
1056            let record = serde_json::json!({"value": 1});
1057            core.put_json(uri_a, &record).await.unwrap();
1058            core.put_json(uri_b, &record).await.unwrap();
1059
1060            // Ensure entries are cached.
1061            core.get_bytes(uri_a).await.unwrap();
1062            core.get_bytes(uri_b).await.unwrap();
1063
1064            core.purge_cache(&[uri_a.to_string()]);
1065
1066            assert!(
1067                core.cached_value(&SecretUri::try_from(uri_a).unwrap())
1068                    .is_none()
1069            );
1070            assert!(
1071                core.cached_value(&SecretUri::try_from(uri_b).unwrap())
1072                    .is_some()
1073            );
1074
1075            core.purge_cache(&["secrets://dev/acme/_/configs/*".to_string()]);
1076            assert!(
1077                core.cached_value(&SecretUri::try_from(uri_b).unwrap())
1078                    .is_none()
1079            );
1080        });
1081    }
1082
1083    #[test]
1084    fn auto_detect_skips_when_backends_present() {
1085        unsafe {
1086            std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
1087        }
1088        rt().block_on(async {
1089            let builder =
1090                CoreBuilder::default().backend(MemoryBackend::new(), MemoryKeyProvider::default());
1091            let builder = builder.auto_detect_backends().await;
1092            let core = builder.build().await.unwrap();
1093            assert_eq!(core.config().backends.len(), 1);
1094            assert_eq!(core.config().backends[0], "custom");
1095        });
1096    }
1097
1098    #[test]
1099    fn auto_detect_respects_backends_env_override() {
1100        unsafe {
1101            std::env::set_var("GREENTIC_SECRETS_BACKENDS", "aws");
1102            std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
1103        }
1104        rt().block_on(async {
1105            let builder = CoreBuilder::default().auto_detect_backends().await;
1106            let core = builder.build().await.unwrap();
1107            assert_eq!(core.config().backends, vec!["memory".to_string()]);
1108        });
1109        unsafe {
1110            std::env::remove_var("GREENTIC_SECRETS_BACKENDS");
1111        }
1112    }
1113}