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)]
148pub enum SecretScope {
149 Deployment(String),
151
152 Service {
154 deployment: String,
156 service: String,
158 },
159
160 Environment {
164 env_id: String,
166 },
167
168 ProjectEnvironment {
172 project_id: String,
174 env_id: String,
176 },
177
178 Custom(String),
184}
185
186impl SecretScope {
187 pub fn deployment(name: impl Into<String>) -> Self {
189 Self::Deployment(name.into())
190 }
191
192 pub fn service(deployment: impl Into<String>, service: impl Into<String>) -> Self {
194 Self::Service {
195 deployment: deployment.into(),
196 service: service.into(),
197 }
198 }
199
200 pub fn environment(env_id: impl Into<String>) -> Self {
202 Self::Environment {
203 env_id: env_id.into(),
204 }
205 }
206
207 pub fn project_environment(project_id: impl Into<String>, env_id: impl Into<String>) -> Self {
209 Self::ProjectEnvironment {
210 project_id: project_id.into(),
211 env_id: env_id.into(),
212 }
213 }
214
215 #[must_use]
221 pub fn for_env(project_id: Option<&str>, env_id: &str) -> Self {
222 match project_id {
223 Some(pid) => Self::project_environment(pid, env_id),
224 None => Self::environment(env_id),
225 }
226 }
227
228 #[must_use]
234 pub fn to_storage_scope(&self) -> String {
235 match self {
236 Self::Deployment(deployment) => deployment.clone(),
237 Self::Service {
238 deployment,
239 service,
240 } => format!("{deployment}/{service}"),
241 Self::Environment { env_id } => format!("env:{env_id}"),
242 Self::ProjectEnvironment { project_id, env_id } => {
243 format!("project:{project_id}:env:{env_id}")
244 }
245 Self::Custom(scope) => scope.clone(),
246 }
247 }
248
249 #[must_use]
261 pub fn from_storage_scope(scope: &str) -> Self {
262 if let Some(env_id) = scope.strip_prefix("env:") {
263 if !env_id.is_empty() {
264 return Self::Environment {
265 env_id: env_id.to_string(),
266 };
267 }
268 }
269 if let Some(rest) = scope.strip_prefix("project:") {
270 if let Some((project_id, env_id)) = rest.split_once(":env:") {
271 if !project_id.is_empty() && !env_id.is_empty() {
272 return Self::ProjectEnvironment {
273 project_id: project_id.to_string(),
274 env_id: env_id.to_string(),
275 };
276 }
277 }
278 }
279 Self::Custom(scope.to_string())
280 }
281
282 #[must_use]
287 pub fn deployment_name(&self) -> &str {
288 match self {
289 Self::Deployment(name) => name,
290 Self::Service { deployment, .. } => deployment,
291 Self::Environment { .. } | Self::ProjectEnvironment { .. } | Self::Custom(_) => "",
292 }
293 }
294
295 #[must_use]
297 pub fn service_name(&self) -> Option<&str> {
298 match self {
299 Self::Service { service, .. } => Some(service),
300 Self::Deployment(_)
301 | Self::Environment { .. }
302 | Self::ProjectEnvironment { .. }
303 | Self::Custom(_) => None,
304 }
305 }
306
307 #[must_use]
312 pub fn environment_id(&self) -> Option<&str> {
313 match self {
314 Self::Environment { env_id } | Self::ProjectEnvironment { env_id, .. } => Some(env_id),
315 Self::Deployment(_) | Self::Service { .. } | Self::Custom(_) => None,
316 }
317 }
318
319 #[must_use]
321 pub fn is_environment_shaped(&self) -> bool {
322 matches!(
323 self,
324 Self::Environment { .. } | Self::ProjectEnvironment { .. }
325 )
326 }
327}
328
329#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
345pub struct SecretRef {
346 pub name: String,
348
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub service: Option<String>,
352
353 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub project: Option<String>,
356
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub environment: Option<String>,
360
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub field: Option<String>,
364}
365
366impl SecretRef {
367 pub const PREFIX: &'static str = "$S:";
369
370 #[must_use]
372 pub fn is_secret_ref(value: &str) -> bool {
373 value.starts_with(Self::PREFIX)
374 }
375
376 #[must_use]
387 pub fn parse(value: &str) -> Option<Self> {
388 let rest = value.strip_prefix(Self::PREFIX)?;
390
391 if rest.is_empty() {
393 return None;
394 }
395
396 if let Some(service_rest) = rest.strip_prefix('@') {
398 let mut parts = service_rest.splitn(3, '/');
399
400 let service = parts.next()?;
401 if service.is_empty() {
402 return None;
403 }
404
405 let name = parts.next()?;
406 if name.is_empty() {
407 return None;
408 }
409
410 let field = parts
411 .next()
412 .map(ToString::to_string)
413 .filter(|s| !s.is_empty());
414
415 return Some(Self {
416 name: name.to_string(),
417 service: Some(service.to_string()),
418 project: None,
419 environment: None,
420 field,
421 });
422 }
423
424 if rest.starts_with('/') {
426 return None;
427 }
428
429 if let Some((scope, tail)) = rest.split_once('/') {
436 if scope.contains(':') {
437 return Self::parse_env_scoped(scope, tail);
438 }
439 if scope.is_empty() {
441 return None;
442 }
443 if tail.is_empty() {
447 return None;
448 }
449 if tail.contains('/') {
452 return None;
453 }
454 return Some(Self {
455 name: scope.to_string(),
456 service: None,
457 project: None,
458 environment: None,
459 field: Some(tail.to_string()),
460 });
461 }
462
463 if rest.contains(':') {
466 return None;
467 }
468 Some(Self {
469 name: rest.to_string(),
470 service: None,
471 project: None,
472 environment: None,
473 field: None,
474 })
475 }
476
477 fn parse_env_scoped(scope: &str, tail: &str) -> Option<Self> {
483 let (project_raw, environment) = scope.split_once(':')?;
485 if environment.is_empty() || environment.contains(':') {
487 return None;
488 }
489 let project = if project_raw.is_empty() {
490 None
491 } else {
492 Some(project_raw.to_string())
493 };
494
495 let (name, field) = match tail.split_once('/') {
497 Some((name, field)) => {
498 if field.is_empty() || field.contains('/') {
499 return None;
500 }
501 (name, Some(field.to_string()))
502 }
503 None => (tail, None),
504 };
505
506 if name.is_empty() {
507 return None;
508 }
509
510 Some(Self {
511 name: name.to_string(),
512 service: None,
513 project,
514 environment: Some(environment.to_string()),
515 field,
516 })
517 }
518
519 #[must_use]
526 pub fn to_scope(&self, deployment: &str) -> SecretScope {
527 match &self.service {
528 Some(service) => SecretScope::Service {
529 deployment: deployment.to_string(),
530 service: service.clone(),
531 },
532 None => SecretScope::Deployment(deployment.to_string()),
533 }
534 }
535
536 #[must_use]
538 pub fn is_deployment_level(&self) -> bool {
539 self.service.is_none() && self.project.is_none() && self.environment.is_none()
540 }
541
542 #[must_use]
544 pub fn is_service_level(&self) -> bool {
545 self.service.is_some()
546 }
547
548 #[must_use]
550 pub fn is_environment_level(&self) -> bool {
551 self.environment.is_some()
552 }
553
554 #[must_use]
556 pub fn is_project_environment_level(&self) -> bool {
557 self.project.is_some() && self.environment.is_some()
558 }
559
560 #[must_use]
562 pub fn has_field(&self) -> bool {
563 self.field.is_some()
564 }
565}
566
567impl std::fmt::Display for SecretRef {
568 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
569 f.write_str(Self::PREFIX)?;
570 if let Some(service) = &self.service {
571 write!(f, "@{service}/{}", self.name)?;
572 if let Some(field) = &self.field {
573 write!(f, "/{field}")?;
574 }
575 } else if let Some(environment) = &self.environment {
576 if let Some(project) = &self.project {
577 write!(f, "{project}:{environment}/{}", self.name)?;
578 } else {
579 write!(f, ":{environment}/{}", self.name)?;
580 }
581 if let Some(field) = &self.field {
582 write!(f, "/{field}")?;
583 }
584 } else {
585 f.write_str(&self.name)?;
587 if let Some(field) = &self.field {
588 write!(f, "/{field}")?;
589 }
590 }
591 Ok(())
592 }
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 #[test]
600 fn test_secret_debug_redacted() {
601 let secret = Secret::new("super-secret-value");
602 let debug_output = format!("{secret:?}");
603 assert_eq!(debug_output, "[REDACTED]");
604 assert!(!debug_output.contains("super-secret-value"));
605 }
606
607 #[test]
608 fn test_secret_expose() {
609 let secret = Secret::new("my-secret");
610 assert_eq!(secret.expose(), "my-secret");
611 }
612
613 #[test]
614 fn test_secret_from_string() {
615 let secret: Secret = "test-secret".into();
616 assert_eq!(secret.expose(), "test-secret");
617
618 let secret: Secret = String::from("another-secret").into();
619 assert_eq!(secret.expose(), "another-secret");
620 }
621
622 #[test]
623 fn test_secret_zeroize() {
624 let mut secret = Secret::new("sensitive-data");
625 secret.zeroize();
626 assert_eq!(secret.expose(), "");
628 }
629
630 #[test]
631 fn test_secret_metadata_new() {
632 let metadata = SecretMetadata::new("test-secret");
633 assert_eq!(metadata.name, "test-secret");
634 assert_eq!(metadata.version, 1);
635 assert!(metadata.created_at > 0);
636 assert_eq!(metadata.created_at, metadata.updated_at);
637 }
638
639 #[test]
640 fn test_secret_metadata_update() {
641 let mut metadata = SecretMetadata::new("test-secret");
642 let original_created = metadata.created_at;
643 let original_version = metadata.version;
644
645 std::thread::sleep(std::time::Duration::from_millis(10));
647 metadata.update();
648
649 assert_eq!(metadata.created_at, original_created);
650 assert!(metadata.updated_at >= original_created);
651 assert_eq!(metadata.version, original_version + 1);
652 }
653
654 #[test]
655 fn test_secret_scope_deployment() {
656 let scope = SecretScope::deployment("my-deployment");
657 assert_eq!(scope.deployment_name(), "my-deployment");
658 assert!(scope.service_name().is_none());
659 assert_eq!(scope.to_storage_scope(), "my-deployment");
660 }
661
662 #[test]
663 fn test_secret_scope_service() {
664 let scope = SecretScope::service("my-deployment", "my-service");
665 assert_eq!(scope.deployment_name(), "my-deployment");
666 assert_eq!(scope.service_name(), Some("my-service"));
667 assert_eq!(scope.to_storage_scope(), "my-deployment/my-service");
668 }
669
670 #[test]
671 fn test_secret_scope_env_round_trip() {
672 let cases = [
673 SecretScope::Environment {
674 env_id: "e1".to_string(),
675 },
676 SecretScope::ProjectEnvironment {
677 project_id: "p1".to_string(),
678 env_id: "e1".to_string(),
679 },
680 SecretScope::Custom("default".to_string()),
681 SecretScope::Custom("weird:scope".to_string()),
682 ];
683 for scope in cases {
684 let rendered = scope.to_storage_scope();
685 assert_eq!(
686 SecretScope::from_storage_scope(&rendered),
687 scope,
688 "round-trip mismatch for {rendered}"
689 );
690 }
691 }
692
693 #[test]
694 fn test_secret_scope_for_env() {
695 assert_eq!(
696 SecretScope::for_env(Some("p1"), "e1").to_storage_scope(),
697 "project:p1:env:e1"
698 );
699 assert_eq!(
700 SecretScope::for_env(None, "e1").to_storage_scope(),
701 "env:e1"
702 );
703 assert_eq!(
704 SecretScope::for_env(Some("p1"), "e1"),
705 SecretScope::project_environment("p1", "e1")
706 );
707 assert_eq!(
708 SecretScope::for_env(None, "e1"),
709 SecretScope::environment("e1")
710 );
711 }
712
713 #[test]
714 fn test_secret_scope_environment_id() {
715 assert_eq!(
716 SecretScope::from_storage_scope("env:abc").environment_id(),
717 Some("abc")
718 );
719 assert_eq!(
720 SecretScope::from_storage_scope("project:p:env:q").environment_id(),
721 Some("q")
722 );
723 assert_eq!(
724 SecretScope::from_storage_scope("default").environment_id(),
725 None
726 );
727 assert!(SecretScope::from_storage_scope("env:abc").is_environment_shaped());
728 assert!(SecretScope::from_storage_scope("project:p:env:q").is_environment_shaped());
729 assert!(!SecretScope::from_storage_scope("default").is_environment_shaped());
730 }
731
732 #[test]
733 fn test_secret_scope_from_storage_scope_fallbacks() {
734 assert_eq!(
736 SecretScope::from_storage_scope("env:"),
737 SecretScope::Custom("env:".to_string())
738 );
739 assert_eq!(
741 SecretScope::from_storage_scope("project::env:q"),
742 SecretScope::Custom("project::env:q".to_string())
743 );
744 assert_eq!(
746 SecretScope::from_storage_scope("project:p:env:"),
747 SecretScope::Custom("project:p:env:".to_string())
748 );
749 assert_eq!(
751 SecretScope::from_storage_scope("my-deployment"),
752 SecretScope::Custom("my-deployment".to_string())
753 );
754 assert_eq!(
755 SecretScope::from_storage_scope("dep/svc"),
756 SecretScope::Custom("dep/svc".to_string())
757 );
758 }
759
760 #[test]
761 fn test_secret_ref_is_secret_ref() {
762 assert!(SecretRef::is_secret_ref("$S:my-secret"));
763 assert!(SecretRef::is_secret_ref("$S:@service/secret"));
764 assert!(!SecretRef::is_secret_ref("my-secret"));
765 assert!(!SecretRef::is_secret_ref("S:my-secret"));
766 assert!(!SecretRef::is_secret_ref("$:my-secret"));
767 }
768
769 #[test]
770 fn test_secret_ref_parse_deployment_level() {
771 let secret_ref = SecretRef::parse("$S:database-password").unwrap();
772 assert_eq!(secret_ref.name, "database-password");
773 assert!(secret_ref.service.is_none());
774 assert!(secret_ref.project.is_none());
775 assert!(secret_ref.environment.is_none());
776 assert!(secret_ref.field.is_none());
777 assert!(secret_ref.is_deployment_level());
778 }
779
780 #[test]
781 fn test_secret_ref_parse_service_level() {
782 let secret_ref = SecretRef::parse("$S:@api/database-password").unwrap();
783 assert_eq!(secret_ref.name, "database-password");
784 assert_eq!(secret_ref.service, Some("api".to_string()));
785 assert!(secret_ref.project.is_none());
786 assert!(secret_ref.environment.is_none());
787 assert!(secret_ref.field.is_none());
788 assert!(secret_ref.is_service_level());
789 }
790
791 #[test]
792 fn test_secret_ref_parse_service_level_with_field() {
793 let secret_ref = SecretRef::parse("$S:@api/database/password").unwrap();
794 assert_eq!(secret_ref.name, "database");
795 assert_eq!(secret_ref.service, Some("api".to_string()));
796 assert_eq!(secret_ref.field, Some("password".to_string()));
797 assert!(secret_ref.has_field());
798 }
799
800 #[test]
801 fn test_secret_ref_parse_deployment_with_field_legacy() {
802 let secret_ref = SecretRef::parse("$S:database/password").unwrap();
804 assert_eq!(secret_ref.name, "database");
805 assert!(secret_ref.service.is_none());
806 assert!(secret_ref.project.is_none());
807 assert!(secret_ref.environment.is_none());
808 assert_eq!(secret_ref.field, Some("password".to_string()));
809 assert!(secret_ref.has_field());
810 }
811
812 #[test]
813 fn test_secret_ref_parse_environment_level() {
814 let secret_ref = SecretRef::parse("$S::staging/db-password").unwrap();
815 assert_eq!(secret_ref.name, "db-password");
816 assert_eq!(secret_ref.environment, Some("staging".to_string()));
817 assert!(secret_ref.project.is_none());
818 assert!(secret_ref.service.is_none());
819 assert!(secret_ref.field.is_none());
820 assert!(secret_ref.is_environment_level());
821 assert!(!secret_ref.is_project_environment_level());
822 assert!(!secret_ref.is_deployment_level());
823 }
824
825 #[test]
826 fn test_secret_ref_parse_environment_level_with_field() {
827 let secret_ref = SecretRef::parse("$S::staging/db-creds/password").unwrap();
828 assert_eq!(secret_ref.name, "db-creds");
829 assert_eq!(secret_ref.environment, Some("staging".to_string()));
830 assert!(secret_ref.project.is_none());
831 assert_eq!(secret_ref.field, Some("password".to_string()));
832 }
833
834 #[test]
835 fn test_secret_ref_parse_project_environment_level() {
836 let secret_ref = SecretRef::parse("$S:myproj:staging/db-password").unwrap();
837 assert_eq!(secret_ref.name, "db-password");
838 assert_eq!(secret_ref.project, Some("myproj".to_string()));
839 assert_eq!(secret_ref.environment, Some("staging".to_string()));
840 assert!(secret_ref.service.is_none());
841 assert!(secret_ref.field.is_none());
842 assert!(secret_ref.is_environment_level());
843 assert!(secret_ref.is_project_environment_level());
844 }
845
846 #[test]
847 fn test_secret_ref_parse_project_environment_with_field() {
848 let secret_ref = SecretRef::parse("$S:myproj:prod/creds/api_key").unwrap();
849 assert_eq!(secret_ref.name, "creds");
850 assert_eq!(secret_ref.project, Some("myproj".to_string()));
851 assert_eq!(secret_ref.environment, Some("prod".to_string()));
852 assert_eq!(secret_ref.field, Some("api_key".to_string()));
853 }
854
855 #[test]
856 fn test_secret_ref_parse_invalid() {
857 assert!(SecretRef::parse("database-password").is_none());
859
860 assert!(SecretRef::parse("$S:").is_none());
862
863 assert!(SecretRef::parse("$S:@/secret").is_none());
865
866 assert!(SecretRef::parse("$S:@service/").is_none());
868
869 assert!(SecretRef::parse("$S:@").is_none());
871
872 assert!(SecretRef::parse("$S:/name").is_none());
874
875 assert!(SecretRef::parse("$S:database/").is_none());
877
878 assert!(SecretRef::parse("$S:::name").is_none());
880
881 assert!(SecretRef::parse("$S::/name").is_none());
883
884 assert!(SecretRef::parse("$S:proj:/name").is_none());
886
887 assert!(SecretRef::parse("$S:a:b:c/name").is_none());
889
890 assert!(SecretRef::parse("$S::env/").is_none());
892
893 assert!(SecretRef::parse("$S:name/field/extra").is_none());
895 }
896
897 #[test]
898 fn test_secret_ref_display_roundtrip() {
899 let cases = [
900 "$S:database-password",
901 "$S:database/password",
902 "$S:@api/database-password",
903 "$S:@api/database/password",
904 "$S::staging/db-password",
905 "$S::staging/db-creds/password",
906 "$S:myproj:staging/db-password",
907 "$S:myproj:prod/creds/api_key",
908 ];
909
910 for input in cases {
911 let parsed =
912 SecretRef::parse(input).unwrap_or_else(|| panic!("failed to parse {input}"));
913 let rendered = parsed.to_string();
914 assert_eq!(rendered, input, "round-trip mismatch for {input}");
915 let reparsed = SecretRef::parse(&rendered)
916 .unwrap_or_else(|| panic!("failed to re-parse {rendered}"));
917 assert_eq!(parsed, reparsed);
918 }
919 }
920
921 #[test]
922 fn test_secret_ref_serde_backcompat() {
923 let json = r#"{"name":"db","service":"api","field":"password"}"#;
925 let parsed: SecretRef = serde_json::from_str(json).unwrap();
926 assert_eq!(parsed.name, "db");
927 assert_eq!(parsed.service, Some("api".to_string()));
928 assert_eq!(parsed.field, Some("password".to_string()));
929 assert!(parsed.project.is_none());
930 assert!(parsed.environment.is_none());
931
932 let minimal = r#"{"name":"db"}"#;
934 let parsed: SecretRef = serde_json::from_str(minimal).unwrap();
935 assert_eq!(parsed.name, "db");
936 assert!(parsed.service.is_none());
937 assert!(parsed.project.is_none());
938 assert!(parsed.environment.is_none());
939 assert!(parsed.field.is_none());
940 }
941
942 #[test]
943 fn test_secret_ref_to_scope() {
944 let secret_ref = SecretRef::parse("$S:my-secret").unwrap();
946 let scope = secret_ref.to_scope("prod");
947 assert_eq!(scope, SecretScope::Deployment("prod".to_string()));
948
949 let secret_ref = SecretRef::parse("$S:@api/my-secret").unwrap();
951 let scope = secret_ref.to_scope("prod");
952 assert_eq!(
953 scope,
954 SecretScope::Service {
955 deployment: "prod".to_string(),
956 service: "api".to_string(),
957 }
958 );
959 }
960
961 #[test]
962 fn test_secret_metadata_serialization() {
963 let metadata = SecretMetadata {
964 name: "test".to_string(),
965 created_at: 1_234_567_890,
966 updated_at: 1_234_567_900,
967 version: 5,
968 };
969
970 let json = serde_json::to_string(&metadata).unwrap();
971 let deserialized: SecretMetadata = serde_json::from_str(&json).unwrap();
972
973 assert_eq!(metadata, deserialized);
974 }
975
976 #[test]
977 fn test_secret_scope_serialization() {
978 let deployment_scope = SecretScope::deployment("my-deploy");
979 let json = serde_json::to_string(&deployment_scope).unwrap();
980 let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
981 assert_eq!(deployment_scope, deserialized);
982
983 let service_scope = SecretScope::service("my-deploy", "my-service");
984 let json = serde_json::to_string(&service_scope).unwrap();
985 let deserialized: SecretScope = serde_json::from_str(&json).unwrap();
986 assert_eq!(service_scope, deserialized);
987 }
988}