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