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