1use anyhow::{Context, Result, bail};
10use base64::{Engine, engine::general_purpose::STANDARD};
11use greentic_secrets_core::http::{Http, HttpResponse};
12use greentic_secrets_spec::{
13 Envelope, KeyProvider, Scope, SecretListItem, SecretMeta, SecretRecord, SecretUri,
14 SecretVersion, SecretsBackend, SecretsError, SecretsResult, VersionedSecret,
15};
16use reqwest::{Certificate, Method, StatusCode};
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value, json};
19use sha2::{Digest, Sha256};
20use std::collections::HashMap;
21use std::fs;
22use std::sync::Arc;
23use std::time::Duration;
24use url::form_urlencoded::byte_serialize;
25
26const DEFAULT_NAMESPACE_PREFIX: &str = "greentic";
27const DEFAULT_MAX_SECRET_SIZE: usize = 1_048_576; const NAMESPACE_MAX_LEN: usize = 63;
29const SECRET_NAME_MAX_LEN: usize = 253;
30const LABEL_KEY: &str = "greentic.ai/key";
31const LABEL_VERSION: &str = "greentic.ai/version";
32const LABEL_ENV: &str = "greentic.ai/env";
33const LABEL_TENANT: &str = "greentic.ai/tenant";
34const LABEL_TEAM: &str = "greentic.ai/team";
35const LABEL_CATEGORY: &str = "greentic.ai/category";
36const LABEL_NAME: &str = "greentic.ai/name";
37const STATUS_LABEL: &str = "greentic.ai/status";
38
39pub struct BackendComponents {
41 pub backend: Box<dyn SecretsBackend>,
42 pub key_provider: Box<dyn KeyProvider>,
43}
44
45pub async fn build_backend() -> Result<BackendComponents> {
47 let config = Arc::new(K8sProviderConfig::from_env()?);
48 let http = config.build_http_client()?;
49
50 let backend = K8sSecretsBackend::new(config.clone(), http.clone());
51 let key_provider = K8sKeyProvider::new(config);
52 Ok(BackendComponents {
53 backend: Box::new(backend),
54 key_provider: Box::new(key_provider),
55 })
56}
57
58#[derive(Clone)]
59struct K8sSecretsBackend {
60 config: Arc<K8sProviderConfig>,
61 http: Http,
62}
63
64impl K8sSecretsBackend {
65 fn new(config: Arc<K8sProviderConfig>, http: Http) -> Self {
66 Self { config, http }
67 }
68
69 fn request(
70 &self,
71 method: Method,
72 path: &str,
73 body: Option<Value>,
74 ) -> SecretsResult<HttpResponse> {
75 let url = format!(
76 "{}/{}",
77 self.config.api_server.trim_end_matches('/'),
78 path.trim_start_matches('/')
79 );
80
81 let mut builder = self.http.request(method, url);
82 builder = builder.bearer_auth(&self.config.bearer_token);
83 if let Some(payload) = body {
84 builder = builder.json(&payload);
85 }
86
87 builder
88 .send()
89 .map_err(|err| SecretsError::Storage(format!("kubernetes request failed: {err}")))
90 }
91
92 fn ensure_namespace(&self, namespace: &str) -> SecretsResult<()> {
93 let path = format!("/api/v1/namespaces/{namespace}");
94 let (status, body) = read_k8s_response(self.request(Method::GET, &path, None)?)?;
95 if status == StatusCode::OK {
96 return Ok(());
97 }
98
99 if status != StatusCode::NOT_FOUND {
100 return Err(SecretsError::Storage(format!(
101 "failed to inspect namespace: {status} {body}"
102 )));
103 }
104
105 let create = json!({
106 "apiVersion": "v1",
107 "kind": "Namespace",
108 "metadata": { "name": namespace },
109 });
110 let (status, body) =
111 read_k8s_response(self.request(Method::POST, "/api/v1/namespaces", Some(create))?)?;
112 if !status.is_success() {
113 return Err(SecretsError::Storage(format!(
114 "failed to create namespace: {status} {body}"
115 )));
116 }
117 Ok(())
118 }
119
120 fn put_secret(&self, namespace: &str, manifest: Value) -> SecretsResult<()> {
121 let path = format!("/api/v1/namespaces/{namespace}/secrets");
122 let (status, body) =
123 read_k8s_response(self.request(Method::POST, &path, Some(manifest))?)?;
124 if !status.is_success() {
125 return Err(SecretsError::Storage(format!(
126 "create secret failed: {status} {body}"
127 )));
128 }
129 Ok(())
130 }
131
132 fn list_versions(&self, namespace: &str, key: &str) -> SecretsResult<Vec<SecretSnapshot>> {
133 let mut snapshots = Vec::new();
134 let selector = format!("{LABEL_KEY}={key}");
135 let selector = percent_encode(&selector);
136 let mut continue_token: Option<String> = None;
137
138 loop {
139 let mut path = format!(
140 "/api/v1/namespaces/{namespace}/secrets?labelSelector={selector}&limit=100"
141 );
142 if let Some(token) = continue_token.as_ref() {
143 path.push_str("&continue=");
144 path.push_str(&percent_encode(token));
145 }
146
147 let (status, body) = read_k8s_response(self.request(Method::GET, &path, None)?)?;
148 if status == StatusCode::NOT_FOUND {
149 break;
150 }
151 if !status.is_success() {
152 return Err(SecretsError::Storage(format!(
153 "list secrets failed: {status} {body}"
154 )));
155 }
156
157 let mut list: SecretList = serde_json::from_str(&body).map_err(|err| {
158 SecretsError::Storage(format!("failed to decode secret list: {err}; body={body}"))
159 })?;
160
161 for item in list.items.drain(..) {
162 if let Some(snapshot) = parse_secret(item)? {
163 snapshots.push(snapshot);
164 }
165 }
166
167 if let Some(token) = list.metadata.and_then(|meta| meta.continue_token) {
168 continue_token = Some(token);
169 continue;
170 }
171 break;
172 }
173
174 snapshots.sort_by_key(|snapshot| snapshot.version);
175 Ok(snapshots)
176 }
177}
178
179fn read_k8s_response(response: HttpResponse) -> SecretsResult<(StatusCode, String)> {
180 let status = response.status();
181 let body = response.text().map_err(|err| {
182 SecretsError::Storage(format!("failed to read kubernetes response body: {err}"))
183 })?;
184 Ok((status, body))
185}
186
187impl SecretsBackend for K8sSecretsBackend {
188 fn put(&self, record: SecretRecord) -> SecretsResult<SecretVersion> {
189 if record.value.len() > self.config.max_secret_size {
190 return Err(SecretsError::Storage(format!(
191 "secret payload exceeds configured Kubernetes limit of {} bytes",
192 self.config.max_secret_size
193 )));
194 }
195
196 if self.config.use_sealed_secrets {
197 return Err(SecretsError::Storage(
198 "sealed secrets mode is not supported by the live Kubernetes backend".into(),
199 ));
200 }
201
202 let namespace = namespace_for_scope(&self.config, record.meta.uri.scope());
203 self.ensure_namespace(&namespace)?;
204
205 let key = canonical_storage_key(&record.meta.uri);
206 let versions = self.list_versions(&namespace, &key)?;
207 let next_version = versions
208 .last()
209 .map(|snapshot| snapshot.version)
210 .unwrap_or(0)
211 .saturating_add(1);
212
213 let name = secret_resource_name(&record.meta.uri, next_version);
214 let manifest = secret_manifest(
215 &record.meta.uri,
216 Some(&record),
217 &namespace,
218 &name,
219 &key,
220 next_version,
221 false,
222 )?;
223 self.put_secret(&namespace, manifest)?;
224
225 Ok(SecretVersion {
226 version: next_version,
227 deleted: false,
228 })
229 }
230
231 fn get(&self, uri: &SecretUri, version: Option<u64>) -> SecretsResult<Option<VersionedSecret>> {
232 let namespace = namespace_for_scope(&self.config, uri.scope());
233 let key = canonical_storage_key(uri);
234 let versions = self.list_versions(&namespace, &key)?;
235
236 if let Some(requested) = version {
237 for snapshot in versions {
238 if snapshot.version == requested && !snapshot.deleted {
239 return snapshot.into_versioned();
240 }
241 }
242 return Ok(None);
243 }
244
245 for snapshot in versions.into_iter().rev() {
246 if snapshot.deleted {
247 continue;
248 }
249 return snapshot.into_versioned();
250 }
251
252 Ok(None)
253 }
254
255 fn list(
256 &self,
257 scope: &Scope,
258 category_prefix: Option<&str>,
259 name_prefix: Option<&str>,
260 ) -> SecretsResult<Vec<SecretListItem>> {
261 let namespace = namespace_for_scope(&self.config, scope);
262 let (status, body) = read_k8s_response(self.request(
263 Method::GET,
264 &format!("/api/v1/namespaces/{namespace}/secrets?limit=250"),
265 None,
266 )?)?;
267 if !status.is_success() {
268 return Err(SecretsError::Storage(format!(
269 "list secrets failed: {status} {body}"
270 )));
271 }
272
273 let mut list: SecretList = serde_json::from_str(&body).map_err(|err| {
274 SecretsError::Storage(format!("failed to decode secret list: {err}; body={body}"))
275 })?;
276
277 let mut items = Vec::new();
278 for item in list.items.drain(..) {
279 let Some(snapshot) = parse_secret(item)? else {
280 continue;
281 };
282 if snapshot.deleted {
283 continue;
284 }
285
286 if let Some(record) = snapshot.record.as_ref() {
287 let record_scope = record.meta.uri.scope();
288 if record_scope.env() != scope.env() || record_scope.tenant() != scope.tenant() {
289 continue;
290 }
291 if let Some(team) = scope.team()
292 && record_scope.team() != Some(team)
293 {
294 continue;
295 }
296 if let Some(prefix) = category_prefix
297 && !record.meta.uri.category().starts_with(prefix)
298 {
299 continue;
300 }
301 if let Some(prefix) = name_prefix
302 && !record.meta.uri.name().starts_with(prefix)
303 {
304 continue;
305 }
306
307 let versioned = snapshot.into_versioned()?;
308 if let Some(versioned) = versioned
309 && let Some(record) = versioned.record()
310 {
311 items.push(SecretListItem::from_meta(
312 &record.meta,
313 Some(versioned.version.to_string()),
314 ));
315 }
316 }
317 }
318
319 Ok(items)
320 }
321
322 fn delete(&self, uri: &SecretUri) -> SecretsResult<SecretVersion> {
323 let namespace = namespace_for_scope(&self.config, uri.scope());
324 let key = canonical_storage_key(uri);
325 let versions = self.list_versions(&namespace, &key)?;
326 if versions.is_empty() {
327 return Err(SecretsError::NotFound {
328 entity: uri.to_string(),
329 });
330 }
331
332 let next_version = versions
333 .last()
334 .map(|snapshot| snapshot.version)
335 .unwrap_or(0)
336 .saturating_add(1);
337
338 let name = secret_resource_name(uri, next_version);
339 let manifest = secret_manifest(uri, None, &namespace, &name, &key, next_version, true)?;
340 self.put_secret(&namespace, manifest)?;
341
342 Ok(SecretVersion {
343 version: next_version,
344 deleted: true,
345 })
346 }
347
348 fn versions(&self, uri: &SecretUri) -> SecretsResult<Vec<SecretVersion>> {
349 let namespace = namespace_for_scope(&self.config, uri.scope());
350 let key = canonical_storage_key(uri);
351 Ok(self
352 .list_versions(&namespace, &key)?
353 .into_iter()
354 .map(|snapshot| SecretVersion {
355 version: snapshot.version,
356 deleted: snapshot.deleted,
357 })
358 .collect())
359 }
360
361 fn exists(&self, uri: &SecretUri) -> SecretsResult<bool> {
362 Ok(self.get(uri, None)?.is_some())
363 }
364}
365
366#[derive(Clone)]
367struct K8sKeyProvider {
368 config: Arc<K8sProviderConfig>,
369}
370
371impl K8sKeyProvider {
372 fn new(config: Arc<K8sProviderConfig>) -> Self {
373 Self { config }
374 }
375
376 fn derive_key(&self, alias: &str) -> Vec<u8> {
377 let mut hasher = Sha256::new();
378 hasher.update(alias.as_bytes());
379 hasher.finalize()[..32].to_vec()
380 }
381}
382
383impl KeyProvider for K8sKeyProvider {
384 fn wrap_dek(&self, scope: &Scope, dek: &[u8]) -> SecretsResult<Vec<u8>> {
385 let alias = self
386 .config
387 .key_aliases
388 .resolve(scope.env(), scope.tenant())
389 .ok_or_else(|| SecretsError::Crypto("missing key material alias".into()))?;
390 let key = self.derive_key(alias);
391 Ok(xor_bytes(&key, dek))
392 }
393
394 fn unwrap_dek(&self, scope: &Scope, wrapped: &[u8]) -> SecretsResult<Vec<u8>> {
395 let alias = self
396 .config
397 .key_aliases
398 .resolve(scope.env(), scope.tenant())
399 .ok_or_else(|| SecretsError::Crypto("missing key material alias".into()))?;
400 let key = self.derive_key(alias);
401 Ok(xor_bytes(&key, wrapped))
402 }
403}
404
405fn xor_bytes(key: &[u8], data: &[u8]) -> Vec<u8> {
406 data.iter()
407 .enumerate()
408 .map(|(idx, byte)| byte ^ key[idx % key.len()])
409 .collect()
410}
411
412#[derive(Clone, Debug)]
413struct K8sProviderConfig {
414 api_server: String,
415 bearer_token: String,
416 ca_bundle: Option<Vec<u8>>,
417 insecure_skip_tls: bool,
418 request_timeout: Duration,
419 namespace_prefix: String,
420 max_secret_size: usize,
421 key_aliases: AliasMap,
422 use_sealed_secrets: bool,
423}
424
425impl K8sProviderConfig {
426 fn from_env() -> Result<Self> {
427 let api_server = std::env::var("K8S_API_SERVER")
428 .context("set K8S_API_SERVER to the Kubernetes API server URL")?;
429
430 let bearer_token = match std::env::var("K8S_BEARER_TOKEN") {
431 Ok(value) => value,
432 Err(_) => {
433 let path = std::env::var("K8S_BEARER_TOKEN_FILE")
434 .context("set K8S_BEARER_TOKEN or K8S_BEARER_TOKEN_FILE")?;
435 String::from_utf8(fs::read(path)?).context("token file is not valid UTF-8")?
436 }
437 };
438
439 let ca_bundle = std::env::var("K8S_CA_BUNDLE")
440 .ok()
441 .map(|path| fs::read(path).context("failed to read K8S_CA_BUNDLE"))
442 .transpose()?;
443
444 let insecure_skip_tls = std::env::var("K8S_INSECURE_SKIP_TLS")
445 .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE"))
446 .unwrap_or(false);
447
448 let request_timeout = std::env::var("K8S_HTTP_TIMEOUT_SECS")
449 .ok()
450 .and_then(|value| value.parse::<u64>().ok())
451 .filter(|value| *value > 0)
452 .map(Duration::from_secs)
453 .unwrap_or_else(|| Duration::from_secs(15));
454
455 let namespace_prefix = std::env::var("K8S_NAMESPACE_PREFIX")
456 .unwrap_or_else(|_| DEFAULT_NAMESPACE_PREFIX.to_string());
457 let max_secret_size = std::env::var("K8S_SECRET_MAX_BYTES")
458 .ok()
459 .and_then(|value| value.parse::<usize>().ok())
460 .unwrap_or(DEFAULT_MAX_SECRET_SIZE);
461
462 Ok(Self {
463 api_server,
464 bearer_token: bearer_token.trim().to_string(),
465 ca_bundle,
466 insecure_skip_tls,
467 request_timeout,
468 namespace_prefix,
469 max_secret_size,
470 key_aliases: AliasMap::from_env("K8S_KEK_ALIAS")?,
471 use_sealed_secrets: cfg!(feature = "sealedsecrets"),
472 })
473 }
474
475 fn build_http_client(&self) -> Result<Http> {
476 let mut builder = reqwest::Client::builder().timeout(self.request_timeout);
477 if let Some(ca) = self.ca_bundle.as_ref() {
478 let cert = Certificate::from_pem(ca)
479 .or_else(|_| Certificate::from_der(ca))
480 .context("failed to parse K8S_CA_BUNDLE")?;
481 builder = builder.add_root_certificate(cert);
482 }
483 if self.insecure_skip_tls {
484 bail!("K8S_INSECURE_SKIP_TLS is not permitted");
485 }
486 Http::from_builder(builder).context("failed to build kubernetes HTTP client")
487 }
488}
489
490#[derive(Clone, Debug)]
491struct AliasMap {
492 default: Option<String>,
493 per_env: HashMap<String, String>,
494 per_tenant: HashMap<(String, String), String>,
495}
496
497impl AliasMap {
498 fn from_env(prefix: &str) -> Result<Self> {
499 let default = std::env::var(prefix).ok();
500 let mut per_env = HashMap::new();
501 let mut per_tenant = HashMap::new();
502 for (key, value) in std::env::vars() {
503 if !key.starts_with(prefix) || key == prefix {
504 continue;
505 }
506 let suffix = key.trim_start_matches(prefix).trim_matches('_');
507 if suffix.is_empty() {
508 continue;
509 }
510 let parts: Vec<&str> = suffix.split('_').collect();
511 match parts.as_slice() {
512 [env] => {
513 per_env.insert(env.to_lowercase(), value.clone());
514 }
515 [env, tenant] => {
516 per_tenant.insert((env.to_lowercase(), tenant.to_lowercase()), value.clone());
517 }
518 _ => {}
519 }
520 }
521 Ok(Self {
522 default,
523 per_env,
524 per_tenant,
525 })
526 }
527
528 fn resolve(&self, env: &str, tenant: &str) -> Option<&str> {
529 self.per_tenant
530 .get(&(env.to_lowercase(), tenant.to_lowercase()))
531 .or_else(|| self.per_env.get(&env.to_lowercase()))
532 .or(self.default.as_ref())
533 .map(String::as_str)
534 }
535}
536
537#[derive(Deserialize)]
538struct SecretList {
539 items: Vec<SecretItem>,
540 #[serde(default)]
541 metadata: Option<ListMeta>,
542}
543
544#[derive(Deserialize)]
545struct ListMeta {
546 #[serde(rename = "continue")]
547 #[serde(default)]
548 continue_token: Option<String>,
549}
550
551#[derive(Deserialize)]
552struct SecretItem {
553 metadata: ItemMeta,
554 #[serde(default)]
555 data: Option<HashMap<String, String>>,
556}
557
558#[derive(Deserialize)]
559struct ItemMeta {
560 #[serde(default)]
561 labels: HashMap<String, String>,
562}
563
564struct SecretSnapshot {
565 version: u64,
566 deleted: bool,
567 record: Option<StoredRecord>,
568}
569
570impl SecretSnapshot {
571 fn into_versioned(self) -> SecretsResult<Option<VersionedSecret>> {
572 if self.deleted {
573 return Ok(Some(VersionedSecret {
574 version: self.version,
575 deleted: true,
576 record: None,
577 }));
578 }
579
580 let record = self
581 .record
582 .ok_or_else(|| SecretsError::Storage("missing record".into()))?
583 .into_record()?;
584
585 Ok(Some(VersionedSecret {
586 version: self.version,
587 deleted: false,
588 record: Some(record),
589 }))
590 }
591}
592
593#[derive(Clone, Serialize, Deserialize)]
594struct StoredRecord {
595 meta: SecretMeta,
596 envelope: StoredEnvelope,
597 value: String,
598}
599
600impl StoredRecord {
601 fn from_record(record: &SecretRecord) -> SecretsResult<Self> {
602 Ok(Self {
603 meta: record.meta.clone(),
604 envelope: StoredEnvelope::from_envelope(&record.envelope),
605 value: STANDARD.encode(&record.value),
606 })
607 }
608
609 fn into_record(self) -> SecretsResult<SecretRecord> {
610 Ok(SecretRecord::new(
611 self.meta,
612 decode_bytes(&self.value)?,
613 self.envelope.into_envelope()?,
614 ))
615 }
616}
617
618#[derive(Clone, Serialize, Deserialize)]
619struct StoredEnvelope {
620 algorithm: String,
621 nonce: String,
622 hkdf_salt: String,
623 wrapped_dek: String,
624}
625
626impl StoredEnvelope {
627 fn from_envelope(envelope: &Envelope) -> Self {
628 Self {
629 algorithm: envelope.algorithm.to_string(),
630 nonce: STANDARD.encode(&envelope.nonce),
631 hkdf_salt: STANDARD.encode(&envelope.hkdf_salt),
632 wrapped_dek: STANDARD.encode(&envelope.wrapped_dek),
633 }
634 }
635
636 fn into_envelope(self) -> SecretsResult<Envelope> {
637 Ok(Envelope {
638 algorithm: self
639 .algorithm
640 .parse()
641 .map_err(|_| SecretsError::Storage("invalid algorithm".into()))?,
642 nonce: decode_bytes(&self.nonce)?,
643 hkdf_salt: decode_bytes(&self.hkdf_salt)?,
644 wrapped_dek: decode_bytes(&self.wrapped_dek)?,
645 })
646 }
647}
648
649fn parse_secret(item: SecretItem) -> SecretsResult<Option<SecretSnapshot>> {
650 let version = item
651 .metadata
652 .labels
653 .get(LABEL_VERSION)
654 .and_then(|value| value.parse::<u64>().ok())
655 .ok_or_else(|| SecretsError::Storage("secret missing version label".into()))?;
656
657 let deleted = item.metadata.labels.get(STATUS_LABEL).map(String::as_str) == Some("deleted");
658
659 let record = if let Some(data) = item.data.and_then(|mut map| map.remove("record")) {
660 let decoded = decode_bytes(&data)?;
661 let stored: StoredRecord = serde_json::from_slice(&decoded).map_err(|err| {
662 SecretsError::Storage(format!("failed to decode secret payload: {err}"))
663 })?;
664 Some(stored)
665 } else {
666 None
667 };
668
669 Ok(Some(SecretSnapshot {
670 version,
671 deleted,
672 record,
673 }))
674}
675
676fn secret_manifest(
677 uri: &SecretUri,
678 record: Option<&SecretRecord>,
679 namespace: &str,
680 name: &str,
681 key: &str,
682 version: u64,
683 deleted: bool,
684) -> SecretsResult<Value> {
685 let mut labels = Map::new();
686 labels.insert(LABEL_KEY.into(), Value::String(key.to_string()));
687 labels.insert(LABEL_VERSION.into(), Value::String(version.to_string()));
688 labels.insert(
689 LABEL_ENV.into(),
690 Value::String(uri.scope().env().to_string()),
691 );
692 labels.insert(
693 LABEL_TENANT.into(),
694 Value::String(uri.scope().tenant().to_string()),
695 );
696 if let Some(team) = uri.scope().team() {
697 labels.insert(LABEL_TEAM.into(), Value::String(team.to_string()));
698 }
699 labels.insert(
700 LABEL_CATEGORY.into(),
701 Value::String(uri.category().to_string()),
702 );
703 labels.insert(LABEL_NAME.into(), Value::String(uri.name().to_string()));
704 if deleted {
705 labels.insert(STATUS_LABEL.into(), Value::String("deleted".into()));
706 }
707
708 let mut metadata = Map::new();
709 metadata.insert("name".into(), Value::String(name.to_string()));
710 metadata.insert("namespace".into(), Value::String(namespace.to_string()));
711 metadata.insert("labels".into(), Value::Object(labels));
712
713 let mut resource = Map::new();
714 resource.insert("apiVersion".into(), Value::String("v1".into()));
715 resource.insert("kind".into(), Value::String("Secret".into()));
716 resource.insert("metadata".into(), Value::Object(metadata));
717 resource.insert("type".into(), Value::String("Opaque".into()));
718
719 if let Some(record) = record {
720 let stored = StoredRecord::from_record(record)?;
721 let payload = serde_json::to_vec(&stored)
722 .map_err(|err| SecretsError::Storage(format!("failed to encode payload: {err}")))?;
723 let mut data = Map::new();
724 data.insert("record".into(), Value::String(STANDARD.encode(payload)));
725 resource.insert("data".into(), Value::Object(data));
726 }
727
728 Ok(Value::Object(resource))
729}
730
731fn namespace_for_scope(config: &K8sProviderConfig, scope: &Scope) -> String {
732 let mut labels = Vec::new();
733 if !config.namespace_prefix.is_empty() {
734 labels.push(sanitize_label(&config.namespace_prefix));
735 }
736 labels.push(sanitize_label(scope.env()));
737 labels.push(sanitize_label(scope.tenant()));
738 join_labels(&labels, NAMESPACE_MAX_LEN)
739}
740
741fn secret_resource_name(uri: &SecretUri, version: u64) -> String {
742 let mut labels = Vec::new();
743 if let Some(team) = uri.scope().team() {
744 labels.push(sanitize_label(team));
745 }
746 labels.push(sanitize_label(uri.category()));
747 labels.push(sanitize_label(uri.name()));
748 labels.push(sanitize_label(&format!("v{version:04}")));
749 join_labels(&labels, SECRET_NAME_MAX_LEN)
750}
751
752fn sanitize_label(value: &str) -> String {
753 let mut label = String::new();
754 for ch in value.chars() {
755 match ch {
756 'a'..='z' | '0'..='9' => label.push(ch),
757 'A'..='Z' => label.push(ch.to_ascii_lowercase()),
758 '-' | '_' | '.' => {
759 if !label.ends_with('-') {
760 label.push('-');
761 }
762 }
763 _ => {}
764 }
765 }
766 while label.starts_with('-') {
767 label.remove(0);
768 }
769 while label.ends_with('-') {
770 label.pop();
771 }
772 if label.is_empty() {
773 "default".into()
774 } else {
775 label
776 }
777}
778
779fn join_labels(labels: &[String], max_len: usize) -> String {
780 let mut result = String::new();
781 for label in labels {
782 if label.is_empty() {
783 continue;
784 }
785 if !result.is_empty() {
786 result.push('-');
787 }
788 result.push_str(label);
789 }
790 if result.is_empty() {
791 result.push_str("default");
792 }
793 if result.len() > max_len {
794 result.truncate(max_len);
795 while result.ends_with('-') {
796 result.pop();
797 }
798 if result.is_empty() {
799 result.push_str("default");
800 }
801 }
802 result
803}
804
805fn canonical_storage_key(uri: &SecretUri) -> String {
806 format!(
807 "{}/{}/{}/{}/{}",
808 uri.scope().env(),
809 uri.scope().tenant(),
810 uri.scope().team().unwrap_or("_"),
811 uri.category(),
812 uri.name()
813 )
814}
815
816fn decode_bytes(input: &str) -> SecretsResult<Vec<u8>> {
817 STANDARD
818 .decode(input.as_bytes())
819 .map_err(|err| SecretsError::Storage(err.to_string()))
820}
821
822fn percent_encode(value: &str) -> String {
823 byte_serialize(value.as_bytes()).collect()
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use greentic_secrets_spec::Scope;
830 use serial_test::serial;
831 use std::env;
832
833 fn set_env(key: &str, value: &str) {
834 unsafe { env::set_var(key, value) };
835 }
836
837 fn clear_env(key: &str) {
838 unsafe { env::remove_var(key) };
839 }
840
841 fn setup_env() {
842 set_env("K8S_API_SERVER", "http://127.0.0.1:9");
843 set_env("K8S_BEARER_TOKEN", "test-token");
844 set_env("K8S_NAMESPACE_PREFIX", "unit");
845 set_env("K8S_KEK_ALIAS", "default");
846 set_env("K8S_HTTP_TIMEOUT_SECS", "1");
847 clear_env("K8S_BEARER_TOKEN_FILE");
848 clear_env("K8S_CA_BUNDLE");
849 }
850
851 #[tokio::test(flavor = "multi_thread")]
852 #[serial]
853 async fn k8s_provider_ok_under_tokio() {
854 setup_env();
855 let BackendComponents { backend, .. } =
856 build_backend().await.expect("k8s backend builds from env");
857
858 let scope = Scope::new("dev", "tenant", None).expect("scope");
859 let result = backend.list(&scope, None, None);
860 assert!(
861 result.is_err(),
862 "list should attempt network and surface the failure without panicking"
863 );
864 }
865}