1use secrecy::{ExposeSecret, SecretString};
7use serde::{Deserialize, Serialize};
8use zeroize::Zeroize;
9
10#[derive(Clone)]
16pub struct Secret {
17 inner: SecretString,
18}
19
20impl Secret {
21 pub fn new(value: impl Into<String>) -> Self {
23 Self {
24 inner: SecretString::from(value.into()),
25 }
26 }
27
28 #[must_use]
33 pub fn expose(&self) -> &str {
34 self.inner.expose_secret()
35 }
36
37 #[must_use]
39 pub fn as_secret_string(&self) -> &SecretString {
40 &self.inner
41 }
42}
43
44impl std::fmt::Debug for Secret {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 f.write_str("[REDACTED]")
47 }
48}
49
50impl Zeroize for Secret {
51 fn zeroize(&mut self) {
52 self.inner = SecretString::from(String::new());
56 }
57}
58
59impl From<String> for Secret {
60 fn from(value: String) -> Self {
61 Self::new(value)
62 }
63}
64
65impl From<&str> for Secret {
66 fn from(value: &str) -> Self {
67 Self::new(value)
68 }
69}
70
71impl From<SecretString> for Secret {
72 fn from(value: SecretString) -> Self {
73 Self { inner: value }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct SecretMetadata {
80 pub name: String,
82
83 pub created_at: i64,
85
86 pub updated_at: i64,
88
89 pub version: u32,
91}
92
93impl SecretMetadata {
94 #[allow(clippy::cast_possible_wrap)]
96 pub fn new(name: impl Into<String>) -> Self {
97 let now = std::time::SystemTime::now()
98 .duration_since(std::time::UNIX_EPOCH)
99 .unwrap_or_default()
100 .as_secs() as i64;
101
102 Self {
103 name: name.into(),
104 created_at: now,
105 updated_at: now,
106 version: 1,
107 }
108 }
109
110 #[allow(clippy::cast_possible_wrap)]
112 pub fn update(&mut self) {
113 let now = std::time::SystemTime::now()
114 .duration_since(std::time::UNIX_EPOCH)
115 .unwrap_or_default()
116 .as_secs() as i64;
117
118 self.updated_at = now;
119 self.version = self.version.saturating_add(1);
120 }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct RotationResult {
126 pub previous_version: Option<u32>,
128 pub new_version: u32,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
134pub enum SecretScope {
135 Deployment(String),
137
138 Service {
140 deployment: String,
142 service: String,
144 },
145}
146
147impl SecretScope {
148 pub fn deployment(name: impl Into<String>) -> Self {
150 Self::Deployment(name.into())
151 }
152
153 pub fn service(deployment: impl Into<String>, service: impl Into<String>) -> Self {
155 Self::Service {
156 deployment: deployment.into(),
157 service: service.into(),
158 }
159 }
160
161 #[must_use]
166 pub fn to_key_prefix(&self) -> String {
167 match self {
168 Self::Deployment(deployment) => format!("deployments/{deployment}/secrets"),
169 Self::Service {
170 deployment,
171 service,
172 } => format!("deployments/{deployment}/services/{service}/secrets"),
173 }
174 }
175
176 #[must_use]
178 pub fn deployment_name(&self) -> &str {
179 match self {
180 Self::Deployment(name) => name,
181 Self::Service { deployment, .. } => deployment,
182 }
183 }
184
185 #[must_use]
187 pub fn service_name(&self) -> Option<&str> {
188 match self {
189 Self::Deployment(_) => None,
190 Self::Service { service, .. } => Some(service),
191 }
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct SecretRef {
212 pub name: String,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub service: Option<String>,
218
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub project: Option<String>,
222
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub environment: Option<String>,
226
227 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub field: Option<String>,
230}
231
232impl SecretRef {
233 pub const PREFIX: &'static str = "$S:";
235
236 #[must_use]
238 pub fn is_secret_ref(value: &str) -> bool {
239 value.starts_with(Self::PREFIX)
240 }
241
242 #[must_use]
253 pub fn parse(value: &str) -> Option<Self> {
254 let rest = value.strip_prefix(Self::PREFIX)?;
256
257 if rest.is_empty() {
259 return None;
260 }
261
262 if let Some(service_rest) = rest.strip_prefix('@') {
264 let mut parts = service_rest.splitn(3, '/');
265
266 let service = parts.next()?;
267 if service.is_empty() {
268 return None;
269 }
270
271 let name = parts.next()?;
272 if name.is_empty() {
273 return None;
274 }
275
276 let field = parts
277 .next()
278 .map(ToString::to_string)
279 .filter(|s| !s.is_empty());
280
281 return Some(Self {
282 name: name.to_string(),
283 service: Some(service.to_string()),
284 project: None,
285 environment: None,
286 field,
287 });
288 }
289
290 if rest.starts_with('/') {
292 return None;
293 }
294
295 if let Some((scope, tail)) = rest.split_once('/') {
302 if scope.contains(':') {
303 return Self::parse_env_scoped(scope, tail);
304 }
305 if scope.is_empty() {
307 return None;
308 }
309 if tail.is_empty() {
313 return None;
314 }
315 if tail.contains('/') {
318 return None;
319 }
320 return Some(Self {
321 name: scope.to_string(),
322 service: None,
323 project: None,
324 environment: None,
325 field: Some(tail.to_string()),
326 });
327 }
328
329 if rest.contains(':') {
332 return None;
333 }
334 Some(Self {
335 name: rest.to_string(),
336 service: None,
337 project: None,
338 environment: None,
339 field: None,
340 })
341 }
342
343 fn parse_env_scoped(scope: &str, tail: &str) -> Option<Self> {
349 let (project_raw, environment) = scope.split_once(':')?;
351 if environment.is_empty() || environment.contains(':') {
353 return None;
354 }
355 let project = if project_raw.is_empty() {
356 None
357 } else {
358 Some(project_raw.to_string())
359 };
360
361 let (name, field) = match tail.split_once('/') {
363 Some((name, field)) => {
364 if field.is_empty() || field.contains('/') {
365 return None;
366 }
367 (name, Some(field.to_string()))
368 }
369 None => (tail, None),
370 };
371
372 if name.is_empty() {
373 return None;
374 }
375
376 Some(Self {
377 name: name.to_string(),
378 service: None,
379 project,
380 environment: Some(environment.to_string()),
381 field,
382 })
383 }
384
385 #[must_use]
392 pub fn to_scope(&self, deployment: &str) -> SecretScope {
393 match &self.service {
394 Some(service) => SecretScope::Service {
395 deployment: deployment.to_string(),
396 service: service.clone(),
397 },
398 None => SecretScope::Deployment(deployment.to_string()),
399 }
400 }
401
402 #[must_use]
404 pub fn is_deployment_level(&self) -> bool {
405 self.service.is_none() && self.project.is_none() && self.environment.is_none()
406 }
407
408 #[must_use]
410 pub fn is_service_level(&self) -> bool {
411 self.service.is_some()
412 }
413
414 #[must_use]
416 pub fn is_environment_level(&self) -> bool {
417 self.environment.is_some()
418 }
419
420 #[must_use]
422 pub fn is_project_environment_level(&self) -> bool {
423 self.project.is_some() && self.environment.is_some()
424 }
425
426 #[must_use]
428 pub fn has_field(&self) -> bool {
429 self.field.is_some()
430 }
431}
432
433impl std::fmt::Display for SecretRef {
434 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435 f.write_str(Self::PREFIX)?;
436 if let Some(service) = &self.service {
437 write!(f, "@{service}/{}", self.name)?;
438 if let Some(field) = &self.field {
439 write!(f, "/{field}")?;
440 }
441 } else if let Some(environment) = &self.environment {
442 if let Some(project) = &self.project {
443 write!(f, "{project}:{environment}/{}", self.name)?;
444 } else {
445 write!(f, ":{environment}/{}", self.name)?;
446 }
447 if let Some(field) = &self.field {
448 write!(f, "/{field}")?;
449 }
450 } else {
451 f.write_str(&self.name)?;
453 if let Some(field) = &self.field {
454 write!(f, "/{field}")?;
455 }
456 }
457 Ok(())
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 #[test]
466 fn test_secret_debug_redacted() {
467 let secret = Secret::new("super-secret-value");
468 let debug_output = format!("{secret:?}");
469 assert_eq!(debug_output, "[REDACTED]");
470 assert!(!debug_output.contains("super-secret-value"));
471 }
472
473 #[test]
474 fn test_secret_expose() {
475 let secret = Secret::new("my-secret");
476 assert_eq!(secret.expose(), "my-secret");
477 }
478
479 #[test]
480 fn test_secret_from_string() {
481 let secret: Secret = "test-secret".into();
482 assert_eq!(secret.expose(), "test-secret");
483
484 let secret: Secret = String::from("another-secret").into();
485 assert_eq!(secret.expose(), "another-secret");
486 }
487
488 #[test]
489 fn test_secret_zeroize() {
490 let mut secret = Secret::new("sensitive-data");
491 secret.zeroize();
492 assert_eq!(secret.expose(), "");
494 }
495
496 #[test]
497 fn test_secret_metadata_new() {
498 let metadata = SecretMetadata::new("test-secret");
499 assert_eq!(metadata.name, "test-secret");
500 assert_eq!(metadata.version, 1);
501 assert!(metadata.created_at > 0);
502 assert_eq!(metadata.created_at, metadata.updated_at);
503 }
504
505 #[test]
506 fn test_secret_metadata_update() {
507 let mut metadata = SecretMetadata::new("test-secret");
508 let original_created = metadata.created_at;
509 let original_version = metadata.version;
510
511 std::thread::sleep(std::time::Duration::from_millis(10));
513 metadata.update();
514
515 assert_eq!(metadata.created_at, original_created);
516 assert!(metadata.updated_at >= original_created);
517 assert_eq!(metadata.version, original_version + 1);
518 }
519
520 #[test]
521 fn test_secret_scope_deployment() {
522 let scope = SecretScope::deployment("my-deployment");
523 assert_eq!(scope.deployment_name(), "my-deployment");
524 assert!(scope.service_name().is_none());
525 assert_eq!(scope.to_key_prefix(), "deployments/my-deployment/secrets");
526 }
527
528 #[test]
529 fn test_secret_scope_service() {
530 let scope = SecretScope::service("my-deployment", "my-service");
531 assert_eq!(scope.deployment_name(), "my-deployment");
532 assert_eq!(scope.service_name(), Some("my-service"));
533 assert_eq!(
534 scope.to_key_prefix(),
535 "deployments/my-deployment/services/my-service/secrets"
536 );
537 }
538
539 #[test]
540 fn test_secret_ref_is_secret_ref() {
541 assert!(SecretRef::is_secret_ref("$S:my-secret"));
542 assert!(SecretRef::is_secret_ref("$S:@service/secret"));
543 assert!(!SecretRef::is_secret_ref("my-secret"));
544 assert!(!SecretRef::is_secret_ref("S:my-secret"));
545 assert!(!SecretRef::is_secret_ref("$:my-secret"));
546 }
547
548 #[test]
549 fn test_secret_ref_parse_deployment_level() {
550 let secret_ref = SecretRef::parse("$S:database-password").unwrap();
551 assert_eq!(secret_ref.name, "database-password");
552 assert!(secret_ref.service.is_none());
553 assert!(secret_ref.project.is_none());
554 assert!(secret_ref.environment.is_none());
555 assert!(secret_ref.field.is_none());
556 assert!(secret_ref.is_deployment_level());
557 }
558
559 #[test]
560 fn test_secret_ref_parse_service_level() {
561 let secret_ref = SecretRef::parse("$S:@api/database-password").unwrap();
562 assert_eq!(secret_ref.name, "database-password");
563 assert_eq!(secret_ref.service, Some("api".to_string()));
564 assert!(secret_ref.project.is_none());
565 assert!(secret_ref.environment.is_none());
566 assert!(secret_ref.field.is_none());
567 assert!(secret_ref.is_service_level());
568 }
569
570 #[test]
571 fn test_secret_ref_parse_service_level_with_field() {
572 let secret_ref = SecretRef::parse("$S:@api/database/password").unwrap();
573 assert_eq!(secret_ref.name, "database");
574 assert_eq!(secret_ref.service, Some("api".to_string()));
575 assert_eq!(secret_ref.field, Some("password".to_string()));
576 assert!(secret_ref.has_field());
577 }
578
579 #[test]
580 fn test_secret_ref_parse_deployment_with_field_legacy() {
581 let secret_ref = SecretRef::parse("$S:database/password").unwrap();
583 assert_eq!(secret_ref.name, "database");
584 assert!(secret_ref.service.is_none());
585 assert!(secret_ref.project.is_none());
586 assert!(secret_ref.environment.is_none());
587 assert_eq!(secret_ref.field, Some("password".to_string()));
588 assert!(secret_ref.has_field());
589 }
590
591 #[test]
592 fn test_secret_ref_parse_environment_level() {
593 let secret_ref = SecretRef::parse("$S::staging/db-password").unwrap();
594 assert_eq!(secret_ref.name, "db-password");
595 assert_eq!(secret_ref.environment, Some("staging".to_string()));
596 assert!(secret_ref.project.is_none());
597 assert!(secret_ref.service.is_none());
598 assert!(secret_ref.field.is_none());
599 assert!(secret_ref.is_environment_level());
600 assert!(!secret_ref.is_project_environment_level());
601 assert!(!secret_ref.is_deployment_level());
602 }
603
604 #[test]
605 fn test_secret_ref_parse_environment_level_with_field() {
606 let secret_ref = SecretRef::parse("$S::staging/db-creds/password").unwrap();
607 assert_eq!(secret_ref.name, "db-creds");
608 assert_eq!(secret_ref.environment, Some("staging".to_string()));
609 assert!(secret_ref.project.is_none());
610 assert_eq!(secret_ref.field, Some("password".to_string()));
611 }
612
613 #[test]
614 fn test_secret_ref_parse_project_environment_level() {
615 let secret_ref = SecretRef::parse("$S:myproj:staging/db-password").unwrap();
616 assert_eq!(secret_ref.name, "db-password");
617 assert_eq!(secret_ref.project, Some("myproj".to_string()));
618 assert_eq!(secret_ref.environment, Some("staging".to_string()));
619 assert!(secret_ref.service.is_none());
620 assert!(secret_ref.field.is_none());
621 assert!(secret_ref.is_environment_level());
622 assert!(secret_ref.is_project_environment_level());
623 }
624
625 #[test]
626 fn test_secret_ref_parse_project_environment_with_field() {
627 let secret_ref = SecretRef::parse("$S:myproj:prod/creds/api_key").unwrap();
628 assert_eq!(secret_ref.name, "creds");
629 assert_eq!(secret_ref.project, Some("myproj".to_string()));
630 assert_eq!(secret_ref.environment, Some("prod".to_string()));
631 assert_eq!(secret_ref.field, Some("api_key".to_string()));
632 }
633
634 #[test]
635 fn test_secret_ref_parse_invalid() {
636 assert!(SecretRef::parse("database-password").is_none());
638
639 assert!(SecretRef::parse("$S:").is_none());
641
642 assert!(SecretRef::parse("$S:@/secret").is_none());
644
645 assert!(SecretRef::parse("$S:@service/").is_none());
647
648 assert!(SecretRef::parse("$S:@").is_none());
650
651 assert!(SecretRef::parse("$S:/name").is_none());
653
654 assert!(SecretRef::parse("$S:database/").is_none());
656
657 assert!(SecretRef::parse("$S:::name").is_none());
659
660 assert!(SecretRef::parse("$S::/name").is_none());
662
663 assert!(SecretRef::parse("$S:proj:/name").is_none());
665
666 assert!(SecretRef::parse("$S:a:b:c/name").is_none());
668
669 assert!(SecretRef::parse("$S::env/").is_none());
671
672 assert!(SecretRef::parse("$S:name/field/extra").is_none());
674 }
675
676 #[test]
677 fn test_secret_ref_display_roundtrip() {
678 let cases = [
679 "$S:database-password",
680 "$S:database/password",
681 "$S:@api/database-password",
682 "$S:@api/database/password",
683 "$S::staging/db-password",
684 "$S::staging/db-creds/password",
685 "$S:myproj:staging/db-password",
686 "$S:myproj:prod/creds/api_key",
687 ];
688
689 for input in cases {
690 let parsed =
691 SecretRef::parse(input).unwrap_or_else(|| panic!("failed to parse {input}"));
692 let rendered = parsed.to_string();
693 assert_eq!(rendered, input, "round-trip mismatch for {input}");
694 let reparsed = SecretRef::parse(&rendered)
695 .unwrap_or_else(|| panic!("failed to re-parse {rendered}"));
696 assert_eq!(parsed, reparsed);
697 }
698 }
699
700 #[test]
701 fn test_secret_ref_serde_backcompat() {
702 let json = r#"{"name":"db","service":"api","field":"password"}"#;
704 let parsed: SecretRef = serde_json::from_str(json).unwrap();
705 assert_eq!(parsed.name, "db");
706 assert_eq!(parsed.service, Some("api".to_string()));
707 assert_eq!(parsed.field, Some("password".to_string()));
708 assert!(parsed.project.is_none());
709 assert!(parsed.environment.is_none());
710
711 let minimal = r#"{"name":"db"}"#;
713 let parsed: SecretRef = serde_json::from_str(minimal).unwrap();
714 assert_eq!(parsed.name, "db");
715 assert!(parsed.service.is_none());
716 assert!(parsed.project.is_none());
717 assert!(parsed.environment.is_none());
718 assert!(parsed.field.is_none());
719 }
720
721 #[test]
722 fn test_secret_ref_to_scope() {
723 let secret_ref = SecretRef::parse("$S:my-secret").unwrap();
725 let scope = secret_ref.to_scope("prod");
726 assert_eq!(scope, SecretScope::Deployment("prod".to_string()));
727
728 let secret_ref = SecretRef::parse("$S:@api/my-secret").unwrap();
730 let scope = secret_ref.to_scope("prod");
731 assert_eq!(
732 scope,
733 SecretScope::Service {
734 deployment: "prod".to_string(),
735 service: "api".to_string(),
736 }
737 );
738 }
739
740 #[test]
741 fn test_secret_metadata_serialization() {
742 let metadata = SecretMetadata {
743 name: "test".to_string(),
744 created_at: 1_234_567_890,
745 updated_at: 1_234_567_900,
746 version: 5,
747 };
748
749 let json = serde_json::to_string(&metadata).unwrap();
750 let deserialized: SecretMetadata = serde_json::from_str(&json).unwrap();
751
752 assert_eq!(metadata, deserialized);
753 }
754
755 #[test]
756 fn test_secret_scope_serialization() {
757 let deployment_scope = SecretScope::deployment("my-deploy");
758 let json = serde_json::to_string(&deployment_scope).unwrap();
759 let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
760 assert_eq!(deployment_scope, deserialized);
761
762 let service_scope = SecretScope::service("my-deploy", "my-service");
763 let json = serde_json::to_string(&service_scope).unwrap();
764 let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
765 assert_eq!(service_scope, deserialized);
766 }
767}