secrets_core/
embedded.rs

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