Skip to main content

zlayer_secrets/
types.rs

1//! Secret types for `ZLayer` secrets management.
2//!
3//! This module provides secure secret handling with proper memory cleanup
4//! and redacted debug output.
5
6use secrecy::{ExposeSecret, SecretString};
7use serde::{Deserialize, Serialize};
8use zeroize::Zeroize;
9
10/// A secure secret wrapper that provides memory safety guarantees.
11///
12/// - Implements `Zeroize` for secure memory cleanup on drop
13/// - Debug output shows `[REDACTED]` instead of the actual value
14/// - Uses `SecretString` from the secrecy crate for the underlying storage
15#[derive(Clone)]
16pub struct Secret {
17    inner: SecretString,
18}
19
20impl Secret {
21    /// Create a new secret from a string value.
22    pub fn new(value: impl Into<String>) -> Self {
23        Self {
24            inner: SecretString::from(value.into()),
25        }
26    }
27
28    /// Expose the secret value for use.
29    ///
30    /// This should only be called when the secret value is actually needed,
31    /// such as when passing to an external service or writing to an encrypted store.
32    #[must_use]
33    pub fn expose(&self) -> &str {
34        self.inner.expose_secret()
35    }
36
37    /// Get the underlying `SecretString` reference.
38    #[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        // SecretString handles its own zeroization on drop,
53        // but we implement the trait for explicit zeroize calls.
54        // We replace with an empty secret to trigger cleanup.
55        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/// Metadata associated with a stored secret.
78#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79pub struct SecretMetadata {
80    /// The name/identifier of the secret.
81    pub name: String,
82
83    /// Unix timestamp when the secret was created.
84    pub created_at: i64,
85
86    /// Unix timestamp when the secret was last updated.
87    pub updated_at: i64,
88
89    /// Version number of the secret (incremented on each update).
90    pub version: u32,
91}
92
93impl SecretMetadata {
94    /// Create new metadata for a secret.
95    #[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    /// Update the metadata for a secret modification.
111    #[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/// Result of a secret rotation — records the version before and after the rotate call.
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125pub struct RotationResult {
126    /// Version number prior to rotation. `None` if the secret did not exist before.
127    pub previous_version: Option<u32>,
128    /// Version number after rotation (always set — it's a fresh write).
129    pub new_version: u32,
130}
131
132/// The scope of a secret - determines visibility and access.
133#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
134pub enum SecretScope {
135    /// Deployment-level secret, accessible by all services in the deployment.
136    Deployment(String),
137
138    /// Service-level secret, accessible only by the specified service.
139    Service {
140        /// The deployment this service belongs to.
141        deployment: String,
142        /// The specific service name.
143        service: String,
144    },
145}
146
147impl SecretScope {
148    /// Create a deployment-scoped secret scope.
149    pub fn deployment(name: impl Into<String>) -> Self {
150        Self::Deployment(name.into())
151    }
152
153    /// Create a service-scoped secret scope.
154    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    /// Generate a key prefix for storage lookups.
162    ///
163    /// Returns a path-like prefix that can be used to organize secrets
164    /// in a hierarchical store.
165    #[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    /// Get the deployment name for this scope.
177    #[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    /// Get the service name if this is a service-scoped secret.
186    #[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/// A reference to a secret, parsed from the `$S:` prefix syntax.
196///
197/// ## Formats
198/// - `$S:name` - Deployment-level secret
199/// - `$S:name/field` - Deployment-level secret with JSON field extraction
200/// - `$S:@service/name[/field]` - Service-level secret
201/// - `$S::env/name[/field]` - Environment-scoped secret (global environment)
202/// - `$S:project:env/name[/field]` - Project-scoped environment secret
203///
204/// ## Environment-scope disambiguation
205/// Environment-scoped references require a colon in the scope segment so
206/// they cannot be confused with the legacy deployment-level `name/field`
207/// form. The leading colon in `$S::env/name` marks "no project, scope is
208/// environment `env`". The `$S:project:env/name` form is unambiguous
209/// because it already contains a colon.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct SecretRef {
212    /// The name of the secret.
213    pub name: String,
214
215    /// Optional service name (for service-scoped secrets).
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub service: Option<String>,
218
219    /// Optional project name (for project-scoped environment secrets).
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub project: Option<String>,
222
223    /// Optional environment name (for environment-scoped secrets).
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub environment: Option<String>,
226
227    /// Optional field to extract from a structured secret (e.g., JSON).
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub field: Option<String>,
230}
231
232impl SecretRef {
233    /// The prefix used to identify secret references in configuration values.
234    pub const PREFIX: &'static str = "$S:";
235
236    /// Check if a string value is a secret reference.
237    #[must_use]
238    pub fn is_secret_ref(value: &str) -> bool {
239        value.starts_with(Self::PREFIX)
240    }
241
242    /// Parse a secret reference from a string.
243    ///
244    /// ## Formats
245    /// - `$S:name` - Deployment-level secret
246    /// - `$S:name/field` - Deployment-level secret with JSON field extraction
247    /// - `$S:@service/name[/field]` - Service-level secret
248    /// - `$S::env/name[/field]` - Environment-scoped secret (global environment)
249    /// - `$S:project:env/name[/field]` - Project-scoped environment secret
250    ///
251    /// Returns `None` if the string is not a valid secret reference.
252    #[must_use]
253    pub fn parse(value: &str) -> Option<Self> {
254        // Must start with the prefix
255        let rest = value.strip_prefix(Self::PREFIX)?;
256
257        // Empty reference is invalid
258        if rest.is_empty() {
259            return None;
260        }
261
262        // Service-scoped: $S:@service/name[/field]
263        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        // Reject leading '/' (e.g. "$S:/name"): no scope/name segment.
291        if rest.starts_with('/') {
292            return None;
293        }
294
295        // Environment-scoped forms: the scope segment (everything before the
296        // first '/') must contain a ':'. This disambiguates against the
297        // legacy deployment-level `name/field` form, which contains no ':'.
298        // Supported:
299        //   $S::env/name[/field]        -> environment-only (empty project)
300        //   $S:project:env/name[/field] -> project-scoped environment
301        if let Some((scope, tail)) = rest.split_once('/') {
302            if scope.contains(':') {
303                return Self::parse_env_scoped(scope, tail);
304            }
305            // Legacy deployment-level with field: name/field
306            if scope.is_empty() {
307                return None;
308            }
309            // Reject stray ':' inside a plain name (e.g. "$S:foo:bar/baz")
310            // is handled above (scope contains ':'); also reject the "name
311            // only" form when it contains colons further down.
312            if tail.is_empty() {
313                return None;
314            }
315            // Tail itself must not contain further '/' after the field
316            // (legacy grammar is exactly `name/field`).
317            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        // No '/' in rest: it's either a plain deployment name or malformed.
330        // A name containing ':' is treated as malformed (e.g. "$S:::name").
331        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    /// Parse the environment-scoped forms (scope segment contains ':').
344    ///
345    /// `scope` is the pre-'/' segment, `tail` is everything after the first
346    /// '/', which must contain at least a non-empty name, optionally
347    /// followed by `/field`.
348    fn parse_env_scoped(scope: &str, tail: &str) -> Option<Self> {
349        // Extract the project/env split.
350        let (project_raw, environment) = scope.split_once(':')?;
351        // Reject environments containing further colons (e.g. "a:b:c").
352        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        // Split tail into name and optional field.
362        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    /// Convert this reference to a `SecretScope` for a given deployment.
386    ///
387    /// Environment/project scopes are not representable by `SecretScope`
388    /// (which only models deployment vs service); those references fall
389    /// back to a deployment-level scope and must be resolved by a higher
390    /// layer that understands environment and project scoping.
391    #[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    /// Check if this is a deployment-level secret reference.
403    #[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    /// Check if this is a service-level secret reference.
409    #[must_use]
410    pub fn is_service_level(&self) -> bool {
411        self.service.is_some()
412    }
413
414    /// Check if this reference is environment-scoped (with or without a project).
415    #[must_use]
416    pub fn is_environment_level(&self) -> bool {
417        self.environment.is_some()
418    }
419
420    /// Check if this reference is scoped to a project environment.
421    #[must_use]
422    pub fn is_project_environment_level(&self) -> bool {
423        self.project.is_some() && self.environment.is_some()
424    }
425
426    /// Check if this reference includes field extraction.
427    #[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            // Deployment-level: `name` or `name/field`.
452            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        // After zeroize, the secret should be empty
493        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        // Simulate time passing
512        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        // Legacy deployment-level with field: `name/field`.
582        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        // No prefix
637        assert!(SecretRef::parse("database-password").is_none());
638
639        // Empty after prefix
640        assert!(SecretRef::parse("$S:").is_none());
641
642        // Empty service name
643        assert!(SecretRef::parse("$S:@/secret").is_none());
644
645        // Empty secret name after service
646        assert!(SecretRef::parse("$S:@service/").is_none());
647
648        // Just @ with no service
649        assert!(SecretRef::parse("$S:@").is_none());
650
651        // Leading slash (empty scope/name)
652        assert!(SecretRef::parse("$S:/name").is_none());
653
654        // Empty name in legacy `name/field` form
655        assert!(SecretRef::parse("$S:database/").is_none());
656
657        // Triple-colon flat form (no '/'): colons disallowed in bare names
658        assert!(SecretRef::parse("$S:::name").is_none());
659
660        // Empty environment in env-only form ("$S::/name")
661        assert!(SecretRef::parse("$S::/name").is_none());
662
663        // Empty environment with project ("proj:/name")
664        assert!(SecretRef::parse("$S:proj:/name").is_none());
665
666        // Extra colon in scope ("a:b:c/name")
667        assert!(SecretRef::parse("$S:a:b:c/name").is_none());
668
669        // Env-scoped with empty name
670        assert!(SecretRef::parse("$S::env/").is_none());
671
672        // Extra trailing segment on legacy deployment form
673        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        // Pre-existing JSON without project/environment fields must still deserialize.
703        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        // Minimal form (just a name).
712        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        // Deployment-level
724        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        // Service-level
729        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}