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 && !tenant.trim().is_empty()
133 {
134 builder.tenant = Some(tenant);
135 }
136
137 if let Ok(team) = std::env::var("GREENTIC_SECRETS_TEAM")
138 && !team.trim().is_empty()
139 {
140 builder.team = Some(team);
141 }
142
143 if let Ok(ttl) = std::env::var("GREENTIC_SECRETS_CACHE_TTL_SECS")
144 && let Ok(seconds) = ttl.parse::<u64>()
145 {
146 builder.default_ttl = Some(Duration::from_secs(seconds.max(1)));
147 }
148
149 if let Ok(url) = std::env::var("GREENTIC_SECRETS_NATS_URL")
150 && !url.trim().is_empty()
151 {
152 builder.nats_url = Some(url);
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 && !root.is_empty()
296 {
297 builder = builder.backend(
298 crate::backend::file::FileBackend::new(root),
299 MemoryKeyProvider::default(),
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 get_secret_with_meta(
418 &self,
419 uri: &str,
420 ) -> Result<crate::BrokerSecret, SecretsError> {
421 let uri = self.parse_uri(uri)?;
422 self.ensure_scope_allowed(uri.scope())?;
423 let secret = self
424 .fetch_secret(&uri)?
425 .ok_or_else(|| SecretsError::not_found(&uri))?;
426 self.store_cache(uri.to_string(), &secret);
427 Ok(secret)
428 }
429
430 pub async fn put_json<T: Serialize>(
432 &self,
433 uri: &str,
434 value: &T,
435 ) -> Result<SecretMeta, SecretsError> {
436 let uri = self.parse_uri(uri)?;
437 self.ensure_scope_allowed(uri.scope())?;
438 let bytes = serde_json::to_vec(value)?;
439 let mut meta = SecretMeta::new(uri.clone(), Visibility::Team, ContentType::Json);
440 meta.description = None;
441
442 {
443 let mut broker = self.broker.lock().unwrap();
444 broker.put_secret(meta.clone(), &bytes)?;
445 }
446
447 self.store_cache(
448 uri.to_string(),
449 &BrokerSecret {
450 version: 0,
451 meta: meta.clone(),
452 payload: bytes.clone(),
453 },
454 );
455
456 Ok(meta)
457 }
458
459 pub async fn delete(&self, uri: &str) -> Result<(), SecretsError> {
461 let uri = self.parse_uri(uri)?;
462 self.ensure_scope_allowed(uri.scope())?;
463 {
464 let broker = self.broker.lock().unwrap();
465 broker.delete_secret(&uri)?;
466 }
467 let mut cache = self.cache.lock().unwrap();
468 cache.pop(&uri.to_string());
469 Ok(())
470 }
471
472 pub async fn list(&self, prefix: &str) -> Result<Vec<SecretMeta>, SecretsError> {
474 let (scope, category_prefix, name_prefix) = parse_prefix(prefix)?;
475 self.ensure_scope_allowed(&scope)?;
476 let items: Vec<SecretListItem> = {
477 let broker = self.broker.lock().unwrap();
478 broker.list_secrets(&scope, category_prefix.as_deref(), name_prefix.as_deref())?
479 };
480
481 let mut metas = Vec::with_capacity(items.len());
482 for item in items {
483 let mut meta = SecretMeta::new(item.uri.clone(), item.visibility, item.content_type);
484 meta.description = None;
485 if self.policy.should_include(&meta) {
486 metas.push(meta);
487 }
488 }
489 Ok(metas)
490 }
491
492 fn parse_uri(&self, uri: &str) -> Result<SecretUri, SecretsError> {
493 Ok(SecretUri::parse(uri)?)
494 }
495
496 fn cached_value(&self, uri: &SecretUri) -> Option<Vec<u8>> {
497 let key = uri.to_string();
498 let mut cache = self.cache.lock().unwrap();
499 if let Some(entry) = cache.get(&key)
500 && entry.expires_at > Instant::now()
501 {
502 return Some(entry.value.clone());
503 }
504 cache.pop(&key);
505 None
506 }
507
508 fn fetch_secret(&self, uri: &SecretUri) -> Result<Option<BrokerSecret>, SecretsError> {
509 let mut broker = self.broker.lock().unwrap();
510 Ok(broker.get_secret(uri)?)
511 }
512
513 fn store_cache(&self, key: String, secret: &BrokerSecret) {
514 let mut cache = self.cache.lock().unwrap();
515 let entry = CacheEntry {
516 value: secret.payload.clone(),
517 meta: secret.meta.clone(),
518 expires_at: Instant::now() + self.cache_ttl,
519 };
520 cache.put(key, entry);
521 }
522
523 fn ensure_scope_allowed(&self, scope: &Scope) -> Result<(), SecretsError> {
524 if scope.tenant() != self.config.tenant {
525 return Err(SecretsError::Builder(format!(
526 "tenant `{}` is not permitted for this runtime (allowed tenant: `{}`)",
527 scope.tenant(),
528 self.config.tenant
529 )));
530 }
531
532 if let Some(expected_team) = self.config.team.as_ref() {
533 match scope.team() {
534 Some(team) if team == expected_team => Ok(()),
535 Some(team) => Err(SecretsError::Builder(format!(
536 "team `{team}` is not permitted for this runtime (allowed team: `{expected_team}`)"
537 ))),
538 None => Ok(()),
539 }
540 } else {
541 Ok(())
542 }
543 }
544
545 #[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
548 pub fn purge_cache(&self, uris: &[String]) {
549 let mut cache = self.cache.lock().unwrap();
550 purge_patterns(&mut cache, uris);
551 }
552}
553
554struct CacheEntry {
555 value: Vec<u8>,
556 #[allow(dead_code)]
557 meta: SecretMeta,
558 expires_at: Instant,
559}
560
561#[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
562fn purge_patterns(cache: &mut LruCache<String, CacheEntry>, patterns: &[String]) {
563 for pattern in patterns {
564 purge_pattern(cache, pattern);
565 }
566}
567
568#[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
569fn purge_pattern(cache: &mut LruCache<String, CacheEntry>, pattern: &str) {
570 if let Some(prefix) = pattern.strip_suffix('*') {
571 let keys: Vec<String> = cache
572 .iter()
573 .filter(|(key, _)| key.starts_with(prefix))
574 .map(|(key, _)| key.clone())
575 .collect();
576 for key in keys {
577 cache.pop(&key);
578 }
579 } else {
580 cache.pop(pattern);
581 }
582}
583
584#[cfg(feature = "nats")]
585fn spawn_invalidation_listener(
586 cache: Arc<Mutex<LruCache<String, CacheEntry>>>,
587 tenant: String,
588 url: String,
589) {
590 let subject = format!("secrets.changed.{tenant}.*");
591 tokio::spawn(async move {
592 if let Ok(client) = async_nats::connect(&url).await
593 && let Ok(mut sub) = client.subscribe(subject).await
594 {
595 while let Some(msg) = sub.next().await {
596 if let Ok(payload) = serde_json::from_slice::<InvalidationMessage>(&msg.payload) {
597 let mut guard = cache.lock().unwrap();
598 purge_patterns(&mut guard, &payload.uris);
599 }
600 }
601 }
602 });
603}
604
605#[cfg(feature = "nats")]
606#[derive(serde::Deserialize)]
607struct InvalidationMessage {
608 uris: Vec<String>,
609}
610
611#[derive(Default)]
613pub struct MemoryBackend {
614 state: Mutex<HashMap<String, Vec<MemoryVersion>>>,
615}
616
617impl MemoryBackend {
618 pub fn new() -> Self {
620 Self::default()
621 }
622}
623
624#[derive(Clone)]
625struct MemoryVersion {
626 version: u64,
627 deleted: bool,
628 record: Option<SecretRecord>,
629}
630
631impl MemoryVersion {
632 fn live(version: u64, record: SecretRecord) -> Self {
633 Self {
634 version,
635 deleted: false,
636 record: Some(record),
637 }
638 }
639
640 fn tombstone(version: u64) -> Self {
641 Self {
642 version,
643 deleted: true,
644 record: None,
645 }
646 }
647
648 fn as_version(&self) -> SecretVersion {
649 SecretVersion {
650 version: self.version,
651 deleted: self.deleted,
652 }
653 }
654
655 fn as_versioned(&self) -> VersionedSecret {
656 VersionedSecret {
657 version: self.version,
658 deleted: self.deleted,
659 record: self.record.clone(),
660 }
661 }
662}
663
664impl SecretsBackend for MemoryBackend {
665 fn put(&self, record: SecretRecord) -> CoreResult<SecretVersion> {
666 let key = record.meta.uri.to_string();
667 let mut guard = self.state.lock().unwrap();
668 let entries = guard.entry(key).or_default();
669 let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
670 entries.push(MemoryVersion::live(next_version, record));
671 Ok(SecretVersion {
672 version: next_version,
673 deleted: false,
674 })
675 }
676
677 fn get(&self, uri: &SecretUri, version: Option<u64>) -> CoreResult<Option<VersionedSecret>> {
678 let key = uri.to_string();
679 let guard = self.state.lock().unwrap();
680 let entries = match guard.get(&key) {
681 Some(entries) => entries,
682 None => return Ok(None),
683 };
684
685 if let Some(target) = version {
686 let entry = entries.iter().find(|entry| entry.version == target);
687 return Ok(entry.cloned().map(|entry| entry.as_versioned()));
688 }
689
690 if matches!(entries.last(), Some(entry) if entry.deleted) {
691 return Ok(None);
692 }
693
694 let latest = entries.iter().rev().find(|entry| !entry.deleted).cloned();
695 Ok(latest.map(|entry| entry.as_versioned()))
696 }
697
698 fn list(
699 &self,
700 scope: &Scope,
701 category_prefix: Option<&str>,
702 name_prefix: Option<&str>,
703 ) -> CoreResult<Vec<SecretListItem>> {
704 let guard = self.state.lock().unwrap();
705 let mut items = Vec::new();
706
707 for versions in guard.values() {
708 if matches!(versions.last(), Some(entry) if entry.deleted) {
709 continue;
710 }
711
712 let latest = match versions.iter().rev().find(|entry| !entry.deleted) {
713 Some(entry) => entry,
714 None => continue,
715 };
716
717 let record = match &latest.record {
718 Some(record) => record,
719 None => continue,
720 };
721
722 let secret_scope = record.meta.scope();
723 if scope.env() != secret_scope.env() || scope.tenant() != secret_scope.tenant() {
724 continue;
725 }
726 if scope.team() != secret_scope.team() {
727 continue;
728 }
729
730 if let Some(prefix) = category_prefix
731 && !record.meta.uri.category().starts_with(prefix)
732 {
733 continue;
734 }
735
736 if let Some(prefix) = name_prefix
737 && !record.meta.uri.name().starts_with(prefix)
738 {
739 continue;
740 }
741
742 items.push(SecretListItem::from_meta(
743 &record.meta,
744 Some(latest.version.to_string()),
745 ));
746 }
747
748 Ok(items)
749 }
750
751 fn delete(&self, uri: &SecretUri) -> CoreResult<SecretVersion> {
752 let key = uri.to_string();
753 let mut guard = self.state.lock().unwrap();
754 let entries = guard.get_mut(&key).ok_or_else(|| CoreError::NotFound {
755 entity: uri.to_string(),
756 })?;
757 let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
758 entries.push(MemoryVersion::tombstone(next_version));
759 Ok(SecretVersion {
760 version: next_version,
761 deleted: true,
762 })
763 }
764
765 fn versions(&self, uri: &SecretUri) -> CoreResult<Vec<SecretVersion>> {
766 let key = uri.to_string();
767 let guard = self.state.lock().unwrap();
768 let entries = guard.get(&key).cloned().unwrap_or_default();
769 Ok(entries
770 .into_iter()
771 .map(|entry| entry.as_version())
772 .collect())
773 }
774
775 fn exists(&self, uri: &SecretUri) -> CoreResult<bool> {
776 let key = uri.to_string();
777 let guard = self.state.lock().unwrap();
778 Ok(guard
779 .get(&key)
780 .and_then(|versions| versions.last())
781 .map(|latest| !latest.deleted)
782 .unwrap_or(false))
783 }
784}
785
786#[derive(Default, Clone)]
788pub struct MemoryKeyProvider {
789 keys: Arc<Mutex<HashMap<String, Vec<u8>>>>,
790}
791
792impl MemoryKeyProvider {
793 pub fn new() -> Self {
795 Self::default()
796 }
797
798 fn key_for_scope(&self, scope: &Scope) -> Vec<u8> {
799 let mut guard = self.keys.lock().unwrap();
800 guard
801 .entry(scope_key(scope))
802 .or_insert_with(|| {
803 let mut buf = vec![0u8; 32];
804 let mut rng = rand::rng();
805 use rand::RngCore;
806 rng.fill_bytes(&mut buf);
807 buf
808 })
809 .clone()
810 }
811}
812
813impl KeyProvider for MemoryKeyProvider {
814 fn wrap_dek(&self, scope: &Scope, dek: &[u8]) -> CoreResult<Vec<u8>> {
815 let key = self.key_for_scope(scope);
816 Ok(xor(&key, dek))
817 }
818
819 fn unwrap_dek(&self, scope: &Scope, wrapped: &[u8]) -> CoreResult<Vec<u8>> {
820 let key = self.key_for_scope(scope);
821 Ok(xor(&key, wrapped))
822 }
823}
824
825fn scope_key(scope: &Scope) -> String {
826 format!(
827 "{}:{}:{}",
828 scope.env(),
829 scope.tenant(),
830 scope.team().unwrap_or("_")
831 )
832}
833
834fn xor(key: &[u8], data: &[u8]) -> Vec<u8> {
835 data.iter()
836 .enumerate()
837 .map(|(idx, byte)| byte ^ key[idx % key.len()])
838 .collect()
839}
840
841fn parse_prefix(prefix: &str) -> Result<(Scope, Option<String>, Option<String>), SecretsError> {
842 const SCHEME: &str = "secrets://";
843 if !prefix.starts_with(SCHEME) {
844 return Err(SecretsError::Builder(
845 "prefix must start with secrets://".into(),
846 ));
847 }
848
849 let rest = &prefix[SCHEME.len()..];
850 let segments: Vec<&str> = rest.split('/').collect();
851 if segments.len() < 3 {
852 return Err(SecretsError::Builder(
853 "prefix must include env/tenant/team segments".into(),
854 ));
855 }
856
857 let env = segments[0];
858 let tenant = segments[1];
859 let team_segment = segments[2];
860 let team = if team_segment == "_" || team_segment.is_empty() {
861 None
862 } else {
863 Some(team_segment.to_string())
864 };
865
866 let scope = Scope::new(env.to_string(), tenant.to_string(), team.clone())?;
867
868 let category_prefix = segments
869 .get(3)
870 .map(|s| s.to_string())
871 .filter(|s| !s.is_empty());
872 let name_prefix = segments
873 .get(4)
874 .map(|s| s.to_string())
875 .filter(|s| !s.is_empty());
876
877 Ok((scope, category_prefix, name_prefix))
878}
879
880#[cfg(test)]
881mod tests {
882 use super::*;
883 use tokio::time::{Duration as TokioDuration, sleep};
884
885 fn rt() -> tokio::runtime::Runtime {
886 tokio::runtime::Builder::new_current_thread()
887 .enable_time()
888 .build()
889 .unwrap()
890 }
891
892 #[test]
893 fn builder_from_env_defaults() {
894 unsafe {
895 std::env::remove_var("GREENTIC_SECRETS_TENANT");
896 std::env::remove_var("GREENTIC_SECRETS_TEAM");
897 std::env::remove_var("GREENTIC_SECRETS_CACHE_TTL_SECS");
898 std::env::remove_var("GREENTIC_SECRETS_NATS_URL");
899 }
900
901 let builder = CoreBuilder::from_env();
902 assert!(builder.tenant.is_none());
903 assert_eq!(builder.backends.len(), 1);
904 }
905
906 #[test]
907 fn roundtrip_put_get_json() {
908 rt().block_on(async {
909 let core = SecretsCore::builder()
910 .tenant("acme")
911 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
912 .build()
913 .await
914 .unwrap();
915
916 let uri = "secrets://dev/acme/_/configs/service";
917 let payload = serde_json::json!({ "token": "secret" });
918 let meta = core.put_json(uri, &payload).await.unwrap();
919 assert_eq!(meta.uri.to_string(), uri);
920
921 let value: serde_json::Value = core.get_json(uri).await.unwrap();
922 assert_eq!(value, payload);
923 });
924 }
925
926 #[test]
927 fn cache_hit_and_expiry() {
928 rt().block_on(async {
929 let ttl = Duration::from_millis(50);
930 let core = SecretsCore::builder()
931 .tenant("acme")
932 .default_ttl(ttl)
933 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
934 .build()
935 .await
936 .unwrap();
937
938 let uri = "secrets://dev/acme/_/configs/cache";
939 core.put_json(uri, &serde_json::json!({"key": "value"}))
940 .await
941 .unwrap();
942
943 core.get_bytes(uri).await.unwrap();
945 let key = uri.to_string();
946 {
947 let cache = core.cache.lock().unwrap();
948 assert!(cache.peek(&key).is_some());
949 }
950
951 core.get_bytes(uri).await.unwrap();
953 {
954 let cache = core.cache.lock().unwrap();
955 assert!(cache.peek(&key).is_some());
956 }
957
958 sleep(TokioDuration::from_millis(75)).await;
959
960 core.get_bytes(uri).await.unwrap();
961 {
962 let cache = core.cache.lock().unwrap();
963 let entry = cache.peek(&key).unwrap();
964 assert!(entry.expires_at > Instant::now());
965 }
966 });
967 }
968
969 #[test]
970 fn cache_invalidation_patterns() {
971 rt().block_on(async {
972 let core = SecretsCore::builder()
973 .tenant("acme")
974 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
975 .build()
976 .await
977 .unwrap();
978
979 let uri_a = "secrets://dev/acme/_/configs/app";
980 let uri_b = "secrets://dev/acme/_/configs/db";
981
982 let record = serde_json::json!({"value": 1});
983 core.put_json(uri_a, &record).await.unwrap();
984 core.put_json(uri_b, &record).await.unwrap();
985
986 core.get_bytes(uri_a).await.unwrap();
988 core.get_bytes(uri_b).await.unwrap();
989
990 core.purge_cache(&[uri_a.to_string()]);
991
992 assert!(
993 core.cached_value(&SecretUri::try_from(uri_a).unwrap())
994 .is_none()
995 );
996 assert!(
997 core.cached_value(&SecretUri::try_from(uri_b).unwrap())
998 .is_some()
999 );
1000
1001 core.purge_cache(&["secrets://dev/acme/_/configs/*".to_string()]);
1002 assert!(
1003 core.cached_value(&SecretUri::try_from(uri_b).unwrap())
1004 .is_none()
1005 );
1006 });
1007 }
1008
1009 #[test]
1010 fn auto_detect_skips_when_backends_present() {
1011 unsafe {
1012 std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
1013 }
1014 rt().block_on(async {
1015 let builder =
1016 CoreBuilder::default().backend(MemoryBackend::new(), MemoryKeyProvider::default());
1017 let builder = builder.auto_detect_backends().await;
1018 let core = builder.build().await.unwrap();
1019 assert_eq!(core.config().backends.len(), 1);
1020 assert_eq!(core.config().backends[0], "custom");
1021 });
1022 }
1023
1024 #[test]
1025 fn auto_detect_respects_backends_env_override() {
1026 unsafe {
1027 std::env::set_var("GREENTIC_SECRETS_BACKENDS", "aws");
1028 std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
1029 }
1030 rt().block_on(async {
1031 let builder = CoreBuilder::default().auto_detect_backends().await;
1032 let core = builder.build().await.unwrap();
1033 assert_eq!(core.config().backends, vec!["memory".to_string()]);
1034 });
1035 unsafe {
1036 std::env::remove_var("GREENTIC_SECRETS_BACKENDS");
1037 }
1038 }
1039}