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