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 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 if entry.expires_at > Instant::now() {
501 return Some(entry.value.clone());
502 }
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 if let Ok(mut sub) = client.subscribe(subject).await {
594 while let Some(msg) = sub.next().await {
595 if let Ok(payload) = serde_json::from_slice::<InvalidationMessage>(&msg.payload)
596 {
597 let mut guard = cache.lock().unwrap();
598 purge_patterns(&mut guard, &payload.uris);
599 }
600 }
601 }
602 }
603 });
604}
605
606#[cfg(feature = "nats")]
607#[derive(serde::Deserialize)]
608struct InvalidationMessage {
609 uris: Vec<String>,
610}
611
612#[derive(Default)]
614pub struct MemoryBackend {
615 state: Mutex<HashMap<String, Vec<MemoryVersion>>>,
616}
617
618impl MemoryBackend {
619 pub fn new() -> Self {
621 Self::default()
622 }
623}
624
625#[derive(Clone)]
626struct MemoryVersion {
627 version: u64,
628 deleted: bool,
629 record: Option<SecretRecord>,
630}
631
632impl MemoryVersion {
633 fn live(version: u64, record: SecretRecord) -> Self {
634 Self {
635 version,
636 deleted: false,
637 record: Some(record),
638 }
639 }
640
641 fn tombstone(version: u64) -> Self {
642 Self {
643 version,
644 deleted: true,
645 record: None,
646 }
647 }
648
649 fn as_version(&self) -> SecretVersion {
650 SecretVersion {
651 version: self.version,
652 deleted: self.deleted,
653 }
654 }
655
656 fn as_versioned(&self) -> VersionedSecret {
657 VersionedSecret {
658 version: self.version,
659 deleted: self.deleted,
660 record: self.record.clone(),
661 }
662 }
663}
664
665impl SecretsBackend for MemoryBackend {
666 fn put(&self, record: SecretRecord) -> CoreResult<SecretVersion> {
667 let key = record.meta.uri.to_string();
668 let mut guard = self.state.lock().unwrap();
669 let entries = guard.entry(key).or_default();
670 let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
671 entries.push(MemoryVersion::live(next_version, record));
672 Ok(SecretVersion {
673 version: next_version,
674 deleted: false,
675 })
676 }
677
678 fn get(&self, uri: &SecretUri, version: Option<u64>) -> CoreResult<Option<VersionedSecret>> {
679 let key = uri.to_string();
680 let guard = self.state.lock().unwrap();
681 let entries = match guard.get(&key) {
682 Some(entries) => entries,
683 None => return Ok(None),
684 };
685
686 if let Some(target) = version {
687 let entry = entries.iter().find(|entry| entry.version == target);
688 return Ok(entry.cloned().map(|entry| entry.as_versioned()));
689 }
690
691 if matches!(entries.last(), Some(entry) if entry.deleted) {
692 return Ok(None);
693 }
694
695 let latest = entries.iter().rev().find(|entry| !entry.deleted).cloned();
696 Ok(latest.map(|entry| entry.as_versioned()))
697 }
698
699 fn list(
700 &self,
701 scope: &Scope,
702 category_prefix: Option<&str>,
703 name_prefix: Option<&str>,
704 ) -> CoreResult<Vec<SecretListItem>> {
705 let guard = self.state.lock().unwrap();
706 let mut items = Vec::new();
707
708 for versions in guard.values() {
709 if matches!(versions.last(), Some(entry) if entry.deleted) {
710 continue;
711 }
712
713 let latest = match versions.iter().rev().find(|entry| !entry.deleted) {
714 Some(entry) => entry,
715 None => continue,
716 };
717
718 let record = match &latest.record {
719 Some(record) => record,
720 None => continue,
721 };
722
723 let secret_scope = record.meta.scope();
724 if scope.env() != secret_scope.env() || scope.tenant() != secret_scope.tenant() {
725 continue;
726 }
727 if scope.team() != secret_scope.team() {
728 continue;
729 }
730
731 if let Some(prefix) = category_prefix {
732 if !record.meta.uri.category().starts_with(prefix) {
733 continue;
734 }
735 }
736
737 if let Some(prefix) = name_prefix {
738 if !record.meta.uri.name().starts_with(prefix) {
739 continue;
740 }
741 }
742
743 items.push(SecretListItem::from_meta(
744 &record.meta,
745 Some(latest.version.to_string()),
746 ));
747 }
748
749 Ok(items)
750 }
751
752 fn delete(&self, uri: &SecretUri) -> CoreResult<SecretVersion> {
753 let key = uri.to_string();
754 let mut guard = self.state.lock().unwrap();
755 let entries = guard.get_mut(&key).ok_or_else(|| CoreError::NotFound {
756 entity: uri.to_string(),
757 })?;
758 let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
759 entries.push(MemoryVersion::tombstone(next_version));
760 Ok(SecretVersion {
761 version: next_version,
762 deleted: true,
763 })
764 }
765
766 fn versions(&self, uri: &SecretUri) -> CoreResult<Vec<SecretVersion>> {
767 let key = uri.to_string();
768 let guard = self.state.lock().unwrap();
769 let entries = guard.get(&key).cloned().unwrap_or_default();
770 Ok(entries
771 .into_iter()
772 .map(|entry| entry.as_version())
773 .collect())
774 }
775
776 fn exists(&self, uri: &SecretUri) -> CoreResult<bool> {
777 let key = uri.to_string();
778 let guard = self.state.lock().unwrap();
779 Ok(guard
780 .get(&key)
781 .and_then(|versions| versions.last())
782 .map(|latest| !latest.deleted)
783 .unwrap_or(false))
784 }
785}
786
787#[derive(Default, Clone)]
789pub struct MemoryKeyProvider {
790 keys: Arc<Mutex<HashMap<String, Vec<u8>>>>,
791}
792
793impl MemoryKeyProvider {
794 pub fn new() -> Self {
796 Self::default()
797 }
798
799 fn key_for_scope(&self, scope: &Scope) -> Vec<u8> {
800 let mut guard = self.keys.lock().unwrap();
801 guard
802 .entry(scope_key(scope))
803 .or_insert_with(|| {
804 let mut buf = vec![0u8; 32];
805 let mut rng = rand::rng();
806 use rand::RngCore;
807 rng.fill_bytes(&mut buf);
808 buf
809 })
810 .clone()
811 }
812}
813
814impl KeyProvider for MemoryKeyProvider {
815 fn wrap_dek(&self, scope: &Scope, dek: &[u8]) -> CoreResult<Vec<u8>> {
816 let key = self.key_for_scope(scope);
817 Ok(xor(&key, dek))
818 }
819
820 fn unwrap_dek(&self, scope: &Scope, wrapped: &[u8]) -> CoreResult<Vec<u8>> {
821 let key = self.key_for_scope(scope);
822 Ok(xor(&key, wrapped))
823 }
824}
825
826fn scope_key(scope: &Scope) -> String {
827 format!(
828 "{}:{}:{}",
829 scope.env(),
830 scope.tenant(),
831 scope.team().unwrap_or("_")
832 )
833}
834
835fn xor(key: &[u8], data: &[u8]) -> Vec<u8> {
836 data.iter()
837 .enumerate()
838 .map(|(idx, byte)| byte ^ key[idx % key.len()])
839 .collect()
840}
841
842fn parse_prefix(prefix: &str) -> Result<(Scope, Option<String>, Option<String>), SecretsError> {
843 const SCHEME: &str = "secrets://";
844 if !prefix.starts_with(SCHEME) {
845 return Err(SecretsError::Builder(
846 "prefix must start with secrets://".into(),
847 ));
848 }
849
850 let rest = &prefix[SCHEME.len()..];
851 let segments: Vec<&str> = rest.split('/').collect();
852 if segments.len() < 3 {
853 return Err(SecretsError::Builder(
854 "prefix must include env/tenant/team segments".into(),
855 ));
856 }
857
858 let env = segments[0];
859 let tenant = segments[1];
860 let team_segment = segments[2];
861 let team = if team_segment == "_" || team_segment.is_empty() {
862 None
863 } else {
864 Some(team_segment.to_string())
865 };
866
867 let scope = Scope::new(env.to_string(), tenant.to_string(), team.clone())?;
868
869 let category_prefix = segments
870 .get(3)
871 .map(|s| s.to_string())
872 .filter(|s| !s.is_empty());
873 let name_prefix = segments
874 .get(4)
875 .map(|s| s.to_string())
876 .filter(|s| !s.is_empty());
877
878 Ok((scope, category_prefix, name_prefix))
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884 use tokio::time::{Duration as TokioDuration, sleep};
885
886 fn rt() -> tokio::runtime::Runtime {
887 tokio::runtime::Builder::new_current_thread()
888 .enable_time()
889 .build()
890 .unwrap()
891 }
892
893 #[test]
894 fn builder_from_env_defaults() {
895 unsafe {
896 std::env::remove_var("GREENTIC_SECRETS_TENANT");
897 std::env::remove_var("GREENTIC_SECRETS_TEAM");
898 std::env::remove_var("GREENTIC_SECRETS_CACHE_TTL_SECS");
899 std::env::remove_var("GREENTIC_SECRETS_NATS_URL");
900 }
901
902 let builder = CoreBuilder::from_env();
903 assert!(builder.tenant.is_none());
904 assert_eq!(builder.backends.len(), 1);
905 }
906
907 #[test]
908 fn roundtrip_put_get_json() {
909 rt().block_on(async {
910 let core = SecretsCore::builder()
911 .tenant("acme")
912 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
913 .build()
914 .await
915 .unwrap();
916
917 let uri = "secrets://dev/acme/_/configs/service";
918 let payload = serde_json::json!({ "token": "secret" });
919 let meta = core.put_json(uri, &payload).await.unwrap();
920 assert_eq!(meta.uri.to_string(), uri);
921
922 let value: serde_json::Value = core.get_json(uri).await.unwrap();
923 assert_eq!(value, payload);
924 });
925 }
926
927 #[test]
928 fn cache_hit_and_expiry() {
929 rt().block_on(async {
930 let ttl = Duration::from_millis(50);
931 let core = SecretsCore::builder()
932 .tenant("acme")
933 .default_ttl(ttl)
934 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
935 .build()
936 .await
937 .unwrap();
938
939 let uri = "secrets://dev/acme/_/configs/cache";
940 core.put_json(uri, &serde_json::json!({"key": "value"}))
941 .await
942 .unwrap();
943
944 core.get_bytes(uri).await.unwrap();
946 let key = uri.to_string();
947 {
948 let cache = core.cache.lock().unwrap();
949 assert!(cache.peek(&key).is_some());
950 }
951
952 core.get_bytes(uri).await.unwrap();
954 {
955 let cache = core.cache.lock().unwrap();
956 assert!(cache.peek(&key).is_some());
957 }
958
959 sleep(TokioDuration::from_millis(75)).await;
960
961 core.get_bytes(uri).await.unwrap();
962 {
963 let cache = core.cache.lock().unwrap();
964 let entry = cache.peek(&key).unwrap();
965 assert!(entry.expires_at > Instant::now());
966 }
967 });
968 }
969
970 #[test]
971 fn cache_invalidation_patterns() {
972 rt().block_on(async {
973 let core = SecretsCore::builder()
974 .tenant("acme")
975 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
976 .build()
977 .await
978 .unwrap();
979
980 let uri_a = "secrets://dev/acme/_/configs/app";
981 let uri_b = "secrets://dev/acme/_/configs/db";
982
983 let record = serde_json::json!({"value": 1});
984 core.put_json(uri_a, &record).await.unwrap();
985 core.put_json(uri_b, &record).await.unwrap();
986
987 core.get_bytes(uri_a).await.unwrap();
989 core.get_bytes(uri_b).await.unwrap();
990
991 core.purge_cache(&[uri_a.to_string()]);
992
993 assert!(
994 core.cached_value(&SecretUri::try_from(uri_a).unwrap())
995 .is_none()
996 );
997 assert!(
998 core.cached_value(&SecretUri::try_from(uri_b).unwrap())
999 .is_some()
1000 );
1001
1002 core.purge_cache(&["secrets://dev/acme/_/configs/*".to_string()]);
1003 assert!(
1004 core.cached_value(&SecretUri::try_from(uri_b).unwrap())
1005 .is_none()
1006 );
1007 });
1008 }
1009
1010 #[test]
1011 fn auto_detect_skips_when_backends_present() {
1012 unsafe {
1013 std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
1014 }
1015 rt().block_on(async {
1016 let builder =
1017 CoreBuilder::default().backend(MemoryBackend::new(), MemoryKeyProvider::default());
1018 let builder = builder.auto_detect_backends().await;
1019 let core = builder.build().await.unwrap();
1020 assert_eq!(core.config().backends.len(), 1);
1021 assert_eq!(core.config().backends[0], "custom");
1022 });
1023 }
1024
1025 #[test]
1026 fn auto_detect_respects_backends_env_override() {
1027 unsafe {
1028 std::env::set_var("GREENTIC_SECRETS_BACKENDS", "aws");
1029 std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
1030 }
1031 rt().block_on(async {
1032 let builder = CoreBuilder::default().auto_detect_backends().await;
1033 let core = builder.build().await.unwrap();
1034 assert_eq!(core.config().backends, vec!["memory".to_string()]);
1035 });
1036 unsafe {
1037 std::env::remove_var("GREENTIC_SECRETS_BACKENDS");
1038 }
1039 }
1040}