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_bytes(
456 &self,
457 uri: &str,
458 value: &[u8],
459 content_type: ContentType,
460 ) -> Result<SecretMeta, SecretsError> {
461 let uri = self.parse_uri(uri)?;
462 self.ensure_scope_allowed(uri.scope())?;
463 let mut meta = SecretMeta::new(uri.clone(), Visibility::Team, content_type);
464 meta.description = None;
465
466 {
467 let mut broker = self.broker.lock().unwrap();
468 broker.put_secret(meta.clone(), value)?;
469 }
470
471 self.store_cache(
472 uri.to_string(),
473 &BrokerSecret {
474 version: 0,
475 meta: meta.clone(),
476 payload: value.to_vec(),
477 },
478 );
479
480 Ok(meta)
481 }
482
483 pub async fn put_text(&self, uri: &str, value: &str) -> Result<SecretMeta, SecretsError> {
487 self.put_bytes(uri, value.as_bytes(), ContentType::Text)
488 .await
489 }
490
491 pub async fn put_json<T: Serialize>(
494 &self,
495 uri: &str,
496 value: &T,
497 ) -> Result<SecretMeta, SecretsError> {
498 let bytes = serde_json::to_vec(value)?;
499 self.put_bytes(uri, &bytes, ContentType::Json).await
500 }
501
502 pub async fn delete(&self, uri: &str) -> Result<(), SecretsError> {
504 let uri = self.parse_uri(uri)?;
505 self.ensure_scope_allowed(uri.scope())?;
506 {
507 let broker = self.broker.lock().unwrap();
508 broker.delete_secret(&uri)?;
509 }
510 let mut cache = self.cache.lock().unwrap();
511 cache.pop(&uri.to_string());
512 Ok(())
513 }
514
515 pub async fn list(&self, prefix: &str) -> Result<Vec<SecretMeta>, SecretsError> {
517 let (scope, category_prefix, name_prefix) = parse_prefix(prefix)?;
518 self.ensure_scope_allowed(&scope)?;
519 let items: Vec<SecretListItem> = {
520 let broker = self.broker.lock().unwrap();
521 broker.list_secrets(&scope, category_prefix.as_deref(), name_prefix.as_deref())?
522 };
523
524 let mut metas = Vec::with_capacity(items.len());
525 for item in items {
526 let mut meta = SecretMeta::new(item.uri.clone(), item.visibility, item.content_type);
527 meta.description = None;
528 if self.policy.should_include(&meta) {
529 metas.push(meta);
530 }
531 }
532 Ok(metas)
533 }
534
535 fn parse_uri(&self, uri: &str) -> Result<SecretUri, SecretsError> {
536 Ok(SecretUri::parse(uri)?)
537 }
538
539 fn cached_value(&self, uri: &SecretUri) -> Option<Vec<u8>> {
540 let key = uri.to_string();
541 let mut cache = self.cache.lock().unwrap();
542 if let Some(entry) = cache.get(&key)
543 && entry.expires_at > Instant::now()
544 {
545 return Some(entry.value.clone());
546 }
547 cache.pop(&key);
548 None
549 }
550
551 fn fetch_secret(&self, uri: &SecretUri) -> Result<Option<BrokerSecret>, SecretsError> {
552 let mut broker = self.broker.lock().unwrap();
553 Ok(broker.get_secret(uri)?)
554 }
555
556 fn store_cache(&self, key: String, secret: &BrokerSecret) {
557 let mut cache = self.cache.lock().unwrap();
558 let entry = CacheEntry {
559 value: secret.payload.clone(),
560 meta: secret.meta.clone(),
561 expires_at: Instant::now() + self.cache_ttl,
562 };
563 cache.put(key, entry);
564 }
565
566 fn ensure_scope_allowed(&self, scope: &Scope) -> Result<(), SecretsError> {
567 if scope.tenant() != self.config.tenant {
568 return Err(SecretsError::Builder(format!(
569 "tenant `{}` is not permitted for this runtime (allowed tenant: `{}`)",
570 scope.tenant(),
571 self.config.tenant
572 )));
573 }
574
575 if let Some(expected_team) = self.config.team.as_ref() {
576 match scope.team() {
577 Some(team) if team == expected_team => Ok(()),
578 Some(team) => Err(SecretsError::Builder(format!(
579 "team `{team}` is not permitted for this runtime (allowed team: `{expected_team}`)"
580 ))),
581 None => Ok(()),
582 }
583 } else {
584 Ok(())
585 }
586 }
587
588 #[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
591 pub fn purge_cache(&self, uris: &[String]) {
592 let mut cache = self.cache.lock().unwrap();
593 purge_patterns(&mut cache, uris);
594 }
595}
596
597struct CacheEntry {
598 value: Vec<u8>,
599 #[allow(dead_code)]
600 meta: SecretMeta,
601 expires_at: Instant,
602}
603
604#[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
605fn purge_patterns(cache: &mut LruCache<String, CacheEntry>, patterns: &[String]) {
606 for pattern in patterns {
607 purge_pattern(cache, pattern);
608 }
609}
610
611#[cfg_attr(not(any(test, feature = "nats")), allow(dead_code))]
612fn purge_pattern(cache: &mut LruCache<String, CacheEntry>, pattern: &str) {
613 if let Some(prefix) = pattern.strip_suffix('*') {
614 let keys: Vec<String> = cache
615 .iter()
616 .filter(|(key, _)| key.starts_with(prefix))
617 .map(|(key, _)| key.clone())
618 .collect();
619 for key in keys {
620 cache.pop(&key);
621 }
622 } else {
623 cache.pop(pattern);
624 }
625}
626
627#[cfg(feature = "nats")]
628fn spawn_invalidation_listener(
629 cache: Arc<Mutex<LruCache<String, CacheEntry>>>,
630 tenant: String,
631 url: String,
632) {
633 let subject = format!("secrets.changed.{tenant}.*");
634 tokio::spawn(async move {
635 if let Ok(client) = async_nats::connect(&url).await
636 && let Ok(mut sub) = client.subscribe(subject).await
637 {
638 while let Some(msg) = sub.next().await {
639 if let Ok(payload) = serde_json::from_slice::<InvalidationMessage>(&msg.payload) {
640 let mut guard = cache.lock().unwrap();
641 purge_patterns(&mut guard, &payload.uris);
642 }
643 }
644 }
645 });
646}
647
648#[cfg(feature = "nats")]
649#[derive(serde::Deserialize)]
650struct InvalidationMessage {
651 uris: Vec<String>,
652}
653
654#[derive(Default)]
656pub struct MemoryBackend {
657 state: Mutex<HashMap<String, Vec<MemoryVersion>>>,
658}
659
660impl MemoryBackend {
661 pub fn new() -> Self {
663 Self::default()
664 }
665}
666
667#[derive(Clone)]
668struct MemoryVersion {
669 version: u64,
670 deleted: bool,
671 record: Option<SecretRecord>,
672}
673
674impl MemoryVersion {
675 fn live(version: u64, record: SecretRecord) -> Self {
676 Self {
677 version,
678 deleted: false,
679 record: Some(record),
680 }
681 }
682
683 fn tombstone(version: u64) -> Self {
684 Self {
685 version,
686 deleted: true,
687 record: None,
688 }
689 }
690
691 fn as_version(&self) -> SecretVersion {
692 SecretVersion {
693 version: self.version,
694 deleted: self.deleted,
695 }
696 }
697
698 fn as_versioned(&self) -> VersionedSecret {
699 VersionedSecret {
700 version: self.version,
701 deleted: self.deleted,
702 record: self.record.clone(),
703 }
704 }
705}
706
707impl SecretsBackend for MemoryBackend {
708 fn put(&self, record: SecretRecord) -> CoreResult<SecretVersion> {
709 let key = record.meta.uri.to_string();
710 let mut guard = self.state.lock().unwrap();
711 let entries = guard.entry(key).or_default();
712 let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
713 entries.push(MemoryVersion::live(next_version, record));
714 Ok(SecretVersion {
715 version: next_version,
716 deleted: false,
717 })
718 }
719
720 fn get(&self, uri: &SecretUri, version: Option<u64>) -> CoreResult<Option<VersionedSecret>> {
721 let key = uri.to_string();
722 let guard = self.state.lock().unwrap();
723 let entries = match guard.get(&key) {
724 Some(entries) => entries,
725 None => return Ok(None),
726 };
727
728 if let Some(target) = version {
729 let entry = entries.iter().find(|entry| entry.version == target);
730 return Ok(entry.cloned().map(|entry| entry.as_versioned()));
731 }
732
733 if matches!(entries.last(), Some(entry) if entry.deleted) {
734 return Ok(None);
735 }
736
737 let latest = entries.iter().rev().find(|entry| !entry.deleted).cloned();
738 Ok(latest.map(|entry| entry.as_versioned()))
739 }
740
741 fn list(
742 &self,
743 scope: &Scope,
744 category_prefix: Option<&str>,
745 name_prefix: Option<&str>,
746 ) -> CoreResult<Vec<SecretListItem>> {
747 let guard = self.state.lock().unwrap();
748 let mut items = Vec::new();
749
750 for versions in guard.values() {
751 if matches!(versions.last(), Some(entry) if entry.deleted) {
752 continue;
753 }
754
755 let latest = match versions.iter().rev().find(|entry| !entry.deleted) {
756 Some(entry) => entry,
757 None => continue,
758 };
759
760 let record = match &latest.record {
761 Some(record) => record,
762 None => continue,
763 };
764
765 let secret_scope = record.meta.scope();
766 if scope.env() != secret_scope.env() || scope.tenant() != secret_scope.tenant() {
767 continue;
768 }
769 if scope.team() != secret_scope.team() {
770 continue;
771 }
772
773 if let Some(prefix) = category_prefix
774 && !record.meta.uri.category().starts_with(prefix)
775 {
776 continue;
777 }
778
779 if let Some(prefix) = name_prefix
780 && !record.meta.uri.name().starts_with(prefix)
781 {
782 continue;
783 }
784
785 items.push(SecretListItem::from_meta(
786 &record.meta,
787 Some(latest.version.to_string()),
788 ));
789 }
790
791 Ok(items)
792 }
793
794 fn delete(&self, uri: &SecretUri) -> CoreResult<SecretVersion> {
795 let key = uri.to_string();
796 let mut guard = self.state.lock().unwrap();
797 let entries = guard.get_mut(&key).ok_or_else(|| CoreError::NotFound {
798 entity: uri.to_string(),
799 })?;
800 let next_version = entries.last().map(|v| v.version + 1).unwrap_or(1);
801 entries.push(MemoryVersion::tombstone(next_version));
802 Ok(SecretVersion {
803 version: next_version,
804 deleted: true,
805 })
806 }
807
808 fn versions(&self, uri: &SecretUri) -> CoreResult<Vec<SecretVersion>> {
809 let key = uri.to_string();
810 let guard = self.state.lock().unwrap();
811 let entries = guard.get(&key).cloned().unwrap_or_default();
812 Ok(entries
813 .into_iter()
814 .map(|entry| entry.as_version())
815 .collect())
816 }
817
818 fn exists(&self, uri: &SecretUri) -> CoreResult<bool> {
819 let key = uri.to_string();
820 let guard = self.state.lock().unwrap();
821 Ok(guard
822 .get(&key)
823 .and_then(|versions| versions.last())
824 .map(|latest| !latest.deleted)
825 .unwrap_or(false))
826 }
827}
828
829#[derive(Default, Clone)]
831pub struct MemoryKeyProvider {
832 keys: Arc<Mutex<HashMap<String, Vec<u8>>>>,
833}
834
835impl MemoryKeyProvider {
836 pub fn new() -> Self {
838 Self::default()
839 }
840
841 fn key_for_scope(&self, scope: &Scope) -> Vec<u8> {
842 let mut guard = self.keys.lock().unwrap();
843 guard
844 .entry(scope_key(scope))
845 .or_insert_with(|| {
846 let mut buf = vec![0u8; 32];
847 let mut rng = rand::rng();
848 use rand::Rng;
849 rng.fill_bytes(&mut buf);
850 buf
851 })
852 .clone()
853 }
854}
855
856impl KeyProvider for MemoryKeyProvider {
857 fn wrap_dek(&self, scope: &Scope, dek: &[u8]) -> CoreResult<Vec<u8>> {
858 let key = self.key_for_scope(scope);
859 Ok(xor(&key, dek))
860 }
861
862 fn unwrap_dek(&self, scope: &Scope, wrapped: &[u8]) -> CoreResult<Vec<u8>> {
863 let key = self.key_for_scope(scope);
864 Ok(xor(&key, wrapped))
865 }
866}
867
868fn scope_key(scope: &Scope) -> String {
869 format!(
870 "{}:{}:{}",
871 scope.env(),
872 scope.tenant(),
873 scope.team().unwrap_or("_")
874 )
875}
876
877fn xor(key: &[u8], data: &[u8]) -> Vec<u8> {
878 data.iter()
879 .enumerate()
880 .map(|(idx, byte)| byte ^ key[idx % key.len()])
881 .collect()
882}
883
884fn parse_prefix(prefix: &str) -> Result<(Scope, Option<String>, Option<String>), SecretsError> {
885 const SCHEME: &str = "secrets://";
886 if !prefix.starts_with(SCHEME) {
887 return Err(SecretsError::Builder(
888 "prefix must start with secrets://".into(),
889 ));
890 }
891
892 let rest = &prefix[SCHEME.len()..];
893 let segments: Vec<&str> = rest.split('/').collect();
894 if segments.len() < 3 {
895 return Err(SecretsError::Builder(
896 "prefix must include env/tenant/team segments".into(),
897 ));
898 }
899
900 let env = segments[0];
901 let tenant = segments[1];
902 let team_segment = segments[2];
903 let team = if team_segment == "_" || team_segment.is_empty() {
904 None
905 } else {
906 Some(team_segment.to_string())
907 };
908
909 let scope = Scope::new(env.to_string(), tenant.to_string(), team.clone())?;
910
911 let category_prefix = segments
912 .get(3)
913 .map(|s| s.to_string())
914 .filter(|s| !s.is_empty());
915 let name_prefix = segments
916 .get(4)
917 .map(|s| s.to_string())
918 .filter(|s| !s.is_empty());
919
920 Ok((scope, category_prefix, name_prefix))
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926 use tokio::time::{Duration as TokioDuration, sleep};
927
928 fn rt() -> tokio::runtime::Runtime {
929 tokio::runtime::Builder::new_current_thread()
930 .enable_time()
931 .build()
932 .unwrap()
933 }
934
935 #[test]
936 fn builder_from_env_defaults() {
937 unsafe {
938 std::env::remove_var("GREENTIC_SECRETS_TENANT");
939 std::env::remove_var("GREENTIC_SECRETS_TEAM");
940 std::env::remove_var("GREENTIC_SECRETS_CACHE_TTL_SECS");
941 std::env::remove_var("GREENTIC_SECRETS_NATS_URL");
942 }
943
944 let builder = CoreBuilder::from_env();
945 assert!(builder.tenant.is_none());
946 assert!(builder.backends.is_empty());
947 assert!(builder.dev_backend_enabled);
948
949 rt().block_on(async {
950 let core = builder.build().await.unwrap();
951 assert_eq!(core.config().backends.len(), 1);
952 });
953 }
954
955 #[test]
956 fn roundtrip_put_get_json() {
957 rt().block_on(async {
958 let core = SecretsCore::builder()
959 .tenant("acme")
960 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
961 .build()
962 .await
963 .unwrap();
964
965 let uri = "secrets://dev/acme/_/configs/service";
966 let payload = serde_json::json!({ "token": "secret" });
967 let meta = core.put_json(uri, &payload).await.unwrap();
968 assert_eq!(meta.uri.to_string(), uri);
969
970 let value: serde_json::Value = core.get_json(uri).await.unwrap();
971 assert_eq!(value, payload);
972 });
973 }
974
975 #[test]
976 fn put_bytes_roundtrips_raw_without_json_framing() {
977 rt().block_on(async {
978 let core = SecretsCore::builder()
979 .tenant("acme")
980 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
981 .build()
982 .await
983 .unwrap();
984
985 let uri = "secrets://dev/acme/_/messaging-telegram/telegram_bot_token";
987 let token = "123:AA-bb_cc";
988 core.put_text(uri, token).await.unwrap();
989 assert_eq!(core.get_bytes(uri).await.unwrap(), token.as_bytes());
990 assert_eq!(core.get_text(uri).await.unwrap(), token);
991
992 let json_uri = "secrets://dev/acme/_/messaging-telegram/json_token";
995 core.put_json(json_uri, &token).await.unwrap();
996 assert_eq!(core.get_bytes(json_uri).await.unwrap(), b"\"123:AA-bb_cc\"");
997 });
998 }
999
1000 #[test]
1001 fn cache_hit_and_expiry() {
1002 rt().block_on(async {
1003 let ttl = Duration::from_millis(50);
1004 let core = SecretsCore::builder()
1005 .tenant("acme")
1006 .default_ttl(ttl)
1007 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
1008 .build()
1009 .await
1010 .unwrap();
1011
1012 let uri = "secrets://dev/acme/_/configs/cache";
1013 core.put_json(uri, &serde_json::json!({"key": "value"}))
1014 .await
1015 .unwrap();
1016
1017 core.get_bytes(uri).await.unwrap();
1019 let key = uri.to_string();
1020 {
1021 let cache = core.cache.lock().unwrap();
1022 assert!(cache.peek(&key).is_some());
1023 }
1024
1025 core.get_bytes(uri).await.unwrap();
1027 {
1028 let cache = core.cache.lock().unwrap();
1029 assert!(cache.peek(&key).is_some());
1030 }
1031
1032 sleep(TokioDuration::from_millis(75)).await;
1033
1034 core.get_bytes(uri).await.unwrap();
1035 {
1036 let cache = core.cache.lock().unwrap();
1037 let entry = cache.peek(&key).unwrap();
1038 assert!(entry.expires_at > Instant::now());
1039 }
1040 });
1041 }
1042
1043 #[test]
1044 fn cache_invalidation_patterns() {
1045 rt().block_on(async {
1046 let core = SecretsCore::builder()
1047 .tenant("acme")
1048 .backend(MemoryBackend::new(), MemoryKeyProvider::default())
1049 .build()
1050 .await
1051 .unwrap();
1052
1053 let uri_a = "secrets://dev/acme/_/configs/app";
1054 let uri_b = "secrets://dev/acme/_/configs/db";
1055
1056 let record = serde_json::json!({"value": 1});
1057 core.put_json(uri_a, &record).await.unwrap();
1058 core.put_json(uri_b, &record).await.unwrap();
1059
1060 core.get_bytes(uri_a).await.unwrap();
1062 core.get_bytes(uri_b).await.unwrap();
1063
1064 core.purge_cache(&[uri_a.to_string()]);
1065
1066 assert!(
1067 core.cached_value(&SecretUri::try_from(uri_a).unwrap())
1068 .is_none()
1069 );
1070 assert!(
1071 core.cached_value(&SecretUri::try_from(uri_b).unwrap())
1072 .is_some()
1073 );
1074
1075 core.purge_cache(&["secrets://dev/acme/_/configs/*".to_string()]);
1076 assert!(
1077 core.cached_value(&SecretUri::try_from(uri_b).unwrap())
1078 .is_none()
1079 );
1080 });
1081 }
1082
1083 #[test]
1084 fn auto_detect_skips_when_backends_present() {
1085 unsafe {
1086 std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
1087 }
1088 rt().block_on(async {
1089 let builder =
1090 CoreBuilder::default().backend(MemoryBackend::new(), MemoryKeyProvider::default());
1091 let builder = builder.auto_detect_backends().await;
1092 let core = builder.build().await.unwrap();
1093 assert_eq!(core.config().backends.len(), 1);
1094 assert_eq!(core.config().backends[0], "custom");
1095 });
1096 }
1097
1098 #[test]
1099 fn auto_detect_respects_backends_env_override() {
1100 unsafe {
1101 std::env::set_var("GREENTIC_SECRETS_BACKENDS", "aws");
1102 std::env::remove_var("GREENTIC_SECRETS_FILE_ROOT");
1103 }
1104 rt().block_on(async {
1105 let builder = CoreBuilder::default().auto_detect_backends().await;
1106 let core = builder.build().await.unwrap();
1107 assert_eq!(core.config().backends, vec!["memory".to_string()]);
1108 });
1109 unsafe {
1110 std::env::remove_var("GREENTIC_SECRETS_BACKENDS");
1111 }
1112 }
1113}