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