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