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